diff --git a/CHANGELOG.md b/CHANGELOG.md index 7254ea9f02f..cc1262f885a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 5.4.3 + +* Fix `` causes its children to flicker ([#10417](https://github.com/marmelab/react-admin/pull/10417)) ([djhi](https://github.com/djhi)) +* [Doc] Remove `` from the docs ([#10416](https://github.com/marmelab/react-admin/pull/10416)) ([slax57](https://github.com/slax57)) +* [Doc] Introduce `` custom cell editors ([#10410](https://github.com/marmelab/react-admin/pull/10410)) ([djhi](https://github.com/djhi)) +* [Doc] Update Access Control instructions following ra-rbac update ([#10409](https://github.com/marmelab/react-admin/pull/10409)) ([djhi](https://github.com/djhi)) +* [Doc] Fix `` doc chapter ([#10406](https://github.com/marmelab/react-admin/pull/10406)) ([erwanMarmelab](https://github.com/erwanMarmelab)) +* Bump nanoid from 3.3.7 to 3.3.8 ([#10414](https://github.com/marmelab/react-admin/pull/10414)) ([dependabot[bot]](https://github.com/apps/dependabot)) + ## 5.4.2 * Fix: Improve AutocompleteInput creation support ([#10391](https://github.com/marmelab/react-admin/pull/10391)) ([djhi](https://github.com/djhi)) diff --git a/docs/AccordionForm.md b/docs/AccordionForm.md index 82431935392..902f53ccba7 100644 --- a/docs/AccordionForm.md +++ b/docs/AccordionForm.md @@ -84,10 +84,13 @@ Here are all the props you can set on the `` component: | Prop | Required | Type | Default | Description | | ------------------------- | -------- | ----------------- | ------- | ---------------------------------------------------------------------------- | +| `authorizationError` | Optional | `ReactNode` | `null` | The content to display when authorization checks fail | | `autoClose` | Optional | `boolean` | - | Set to `true` to close the current accordion when opening another one. | | `children` | Required | `ReactNode` | - | A list of `` elements. | | `defaultValues` | Optional | `object|function` | - | The default values of the record. | +| `enableAccessControl` | Optional | `boolean` | `false` | Enable checking authorization rights for each panel and input | | `id` | Optional | `string` | - | The id of the underlying `
` tag. | +| `loading` | Optional | `ReactNode` | | The content to display when checking authorizations | | `noValidate` | Optional | `boolean` | - | Set to `true` to disable the browser's default validation. | | `onSubmit` | Optional | `function` | `save` | A callback to call when the form is submitted. | | `sanitize EmptyValues` | Optional | `boolean` | - | Set to `true` to remove empty values from the form state. | @@ -98,6 +101,48 @@ Here are all the props you can set on the `` component: Additional props are passed to `react-hook-form`'s [`useForm` hook](https://react-hook-form.com/docs/useform). +## `authorizationError` + +Content displayed when `enableAccessControl` is set to `true` and an error occurs while checking for users permissions. Defaults to `null`: + +{% raw %} +```tsx +import { ArrayInput, Edit, DateInput, SimpleFormIterator, TextInput } from 'react-admin'; +import { AccordionForm } from '@react-admin/ra-form-layout'; +import { Alert } from '@mui/material'; + +const CustomerEdit = () => ( + + + An error occurred while loading your permissions + + } + > + + + + + + + + + + + + + + + +); +``` +{% endraw %} + ## `autoClose` When setting the `` prop, only one accordion remains open at a time. The first accordion is open by default, and when a user opens another one, the current open accordion closes. @@ -165,6 +210,53 @@ export const PostCreate = () => ( **Tip**: React-admin also allows to define default values at the input level. See the [Setting default Values](./Forms.md#default-values) section. +## `enableAccessControl` + +When set to `true`, React-admin will call the `authProvider.canAccess` method for each panel with the following parameters: +- `action`: `write` +- `resource`: `RESOURCE_NAME.panel.PANEL_ID_OR_LABEL`. For instance: `customers.panel.identity` +- `record`: The current record + +For each panel, react-admin will also call the `authProvider.canAccess` method for each input with the following parameters: +- `action`: `write` +- `resource`: `RESOURCE_NAME.INPUT_SOURCE`. For instance: `customers.first_name` +- `record`: The current record + +**Tip**: `` direct children that don't have a `source` will always be displayed. + +```tsx +import { + ArrayInput, + Edit, + DateInput, + SimpleFormIterator, + TextInput +} from 'react-admin'; +import { AccordionForm } from '@react-admin/ra-form-layout'; + +const CustomerEdit = () => ( + + + + + + + + + + + + + + + + + +); +``` + +**Tip**: If you only want access control for the panels but not for the inputs, set the `enableAccessControl` prop to `false` on the ``. + ## `id` Normally, a submit button only works when placed inside a `` tag. However, you can place a submit button outside the form if the submit button `form` matches the form `id`. @@ -186,6 +278,43 @@ export const PostCreate = () => ( ); ``` +## `loading` + +Content displayed when `enableAccessControl` is set to `true` while checking for users permissions. Defaults to `Loading` from `react-admin`: + +```tsx +import { ArrayInput, Edit, DateInput, SimpleFormIterator, TextInput } from 'react-admin'; +import { AccordionForm } from '@react-admin/ra-form-layout'; +import { Typography } from '@mui/material'; + +const CustomerEdit = () => ( + + + Loading your permissions... + + } + > + + + + + + + + + + + + + + + +); +``` + ## `noValidate` The `` attribute prevents the browser from validating the form. This is useful if you don't want to use the browser's default validation, or if you want to customize the error messages. To set this attribute on the underlying `` tag, set the `noValidate` prop to `true`. @@ -377,11 +506,14 @@ Here are all the props you can set on the `` component: | Prop | Required | Type | Default | Description | | ----------------- | -------- | ----------------------- | ------- | ------------------------------------------------------------------------------------------------ | +| `authorizationError` | Optional | `ReactNode` | `null` | The content to display when authorization checks fail | | `children` | Required | `ReactNode` | - | A list of `` elements | | `defaultExpanded` | Optional | `boolean` | `false` | Set to true to have the accordion expanded by default (except if autoClose = true on the parent) | | `disabled` | Optional | `boolean` | `false` | If true, the accordion will be displayed in a disabled state. | +| `enableAccessControl` | Optional | `boolean` | `false` | Enable checking authorization rights for this panel's inputs | | `id` | Optional | `string` | - | An id for this Accordion to be used in the [`useFormGroup`](./Upgrade.md#useformgroup-hook-returned-state-has-changed) hook and for CSS classes. | | `label` | Required | `string` or `ReactNode` | - | The main label used as the accordion summary. Appears in red when the accordion has errors | +| `loading` | Optional | `ReactNode` | | The content to display when checking authorizations | | `secondary` | Optional | `string` or `ReactNode` | - | The secondary label used as the accordion summary | | `square` | Optional | `boolean` | `false` | If true, rounded corners are disabled. | | `sx` | Optional | `Object` | - | An object containing the MUI style overrides to apply to the root component. | @@ -433,6 +565,8 @@ Here are all the props you can set on the `` component: | Prop | Required | Type | Default | Description | | ------------------ | -------- | ----------------------- | ------- | ------------------------------------------------------------- | +| `accessDenied` | Optional | `Component` | - | The component to use when users don't have the permissions required to access this section. | +| `authorizationError` | Optional | `Component` | - | The component to use when an error occurs while checking permissions. | | `Accordion` | Optional | `Component` | - | The component to use as the accordion. | | `AccordionDetails` | Optional | `Component` | - | The component to use as the accordion details. | | `AccordionSummary` | Optional | `Component` | - | The component to use as the accordion summary. | @@ -440,9 +574,11 @@ Here are all the props you can set on the `` component: | `className` | Optional | `string` | - | A class name to style the underlying `` | | `defaultExpanded` | Optional | `boolean` | `false` | Set to true to have the accordion expanded by default | | `disabled` | Optional | `boolean` | `false` | If true, the accordion will be displayed in a disabled state. | +| `enableAccessControl` | Optional | `boolean` | - | Enable access control to the section and its inputs | | `fullWidth` | Optional | `boolean` | `false` | If true, the Accordion takes the entire form width. | | `id` | Optional | `string` | - | An id for this Accordion to be used for CSS classes. | | `label` | Required | `string` or `ReactNode` | - | The main label used as the accordion summary. | +| `loading` | Optional | `Component` | - | The component to use while checking permissions. | | `secondary` | Optional | `string` or `ReactNode` | - | The secondary label used as the accordion summary | | `square` | Optional | `boolean` | `false` | If true, rounded corners are disabled. | @@ -491,6 +627,173 @@ const CustomerEdit = () => ( ); ``` +### `accessDenied` + +Content displayed when `enableAccessControl` is set to `true` and users don't have access to the section. Defaults to `null`: + +{% raw %} +```tsx +import { ReactNode } from 'react'; +import { ArrayInput, BooleanInput, Edit, DateInput, SimpleFormIterator, TextInput } from 'react-admin'; +import { AccordionSection } from '@react-admin/ra-form-layout'; +import { Alert } from '@mui/material'; + +const AccessDenied = ({ children }: { children: ReactNode }) => ( + + Upgrade to Premium + + } + > + {children} + +) + +const CustomerEdit = () => ( + + + + + You don't have access to the preferences section} + > + + + + + + +); +``` +{% endraw %} + + +### `authorizationError` + +Content displayed when `enableAccessControl` is set to `true` and an error occurs while checking for users permissions. Defaults to `null`: + +{% raw %} +```tsx +import { ArrayInput, BooleanInput, Edit, DateInput, SimpleFormIterator, TextInput } from 'react-admin'; +import { AccordionSection } from '@react-admin/ra-form-layout'; +import { Alert } from '@mui/material'; + +const AuthorizationError = () => ( + + An error occurred while loading your permissions + +); + +const CustomerEdit = () => ( + + + + + }> + + + + + + +); +``` +{% endraw %} + +### `enableAccessControl` + +When set to `true`, React-admin will call the `authProvider.canAccess` method the following parameters: +- `action`: `write` +- `resource`: `RESOURCE_NAME.section.PANEL_ID_OR_LABEL`. For instance: `customers.section.identity` +- `record`: The current record + +React-admin will also call the `authProvider.canAccess` method for each input with the following parameters: +- `action`: `write` +- `resource`: `RESOURCE_NAME.INPUT_SOURCE`. For instance: `customers.first_name` +- `record`: The current record + +```tsx +import { ArrayInput, BooleanInput, Edit, DateInput, SimpleFormIterator, TextInput } from 'react-admin'; +import { AccordionSection } from '@react-admin/ra-form-layout'; + +const CustomerEdit = () => ( + + + + + + + + + + + + + + +); +``` + +**Tip**: `` direct children that don't have a `source` will always be displayed. + +### `loading` + +Content displayed when `enableAccessControl` is set to `true` while checking for users permissions. Defaults to `Loading` from `react-admin`: + +```tsx +import { ArrayInput, BooleanInput, Edit, DateInput, SimpleFormIterator, TextInput } from 'react-admin'; +import { AccordionSection } from '@react-admin/ra-form-layout'; +import { Typography } from '@mui/material'; + +const AuthorizationLoading = () => ( + + Loading your permissions... + +); + +const CustomerEdit = () => ( + + + + + + + + }> + + + + + + +); +``` + + ## AutoSave In forms where users may spend a lot of time, it's a good idea to save the form automatically after a few seconds of inactivity. You turn on this feature by using [the `` component](./AutoSave.md). @@ -529,59 +832,43 @@ const PersonEdit = () => ( Note that you **must** set the `` prop to `{ keepDirtyValues: true }`. If you forget that prop, any change entered by the end user after the autosave but before its acknowledgement by the server will be lost. -If you're using it in an `` page, you must also use a `pessimistic` or `optimistic` [`mutationMode`](https://marmelab.com/react-admin/Edit.html#mutationmode) - `` doesn't work with the default `mutationMode="undoable"`. +If you're using it in an `` page, you must also use a `pessimistic` or `optimistic` [`mutationMode`](./Edit.md#mutationmode) - `` doesn't work with the default `mutationMode="undoable"`. Check [the `` component](./AutoSave.md) documentation for more details. -## Role-Based Access Control (RBAC) +## Access Control -Fine-grained permissions control can be added by using the [``](./AuthRBAC.md#accordionform), [``](./AuthRBAC.md#accordionform) and [``](./AuthRBAC.md#accordionsection) components provided by the `@react-admin/ra-enterprise` package. +`` can use [Access Control](./AccessControl.md) to check permissions for each section and input. To enable this feature, set the `enableAccessControl` prop to `true`. + +Check the [`enableAccessControl` prop](#enableaccesscontrol) section for more details. -{% raw %} ```tsx -import { AccordionForm } from '@react-admin/ra-enterprise'; - -const authProvider = { - checkAuth: () => Promise.resolve(), - login: () => Promise.resolve(), - logout: () => Promise.resolve(), - checkError: () => Promise.resolve(), - getPermissions: () =>Promise.resolve([ - // 'delete' is missing - { action: ['list', 'edit'], resource: 'products' }, - { action: 'write', resource: 'products.reference' }, - { action: 'write', resource: 'products.width' }, - { action: 'write', resource: 'products.height' }, - // 'products.description' is missing - { action: 'write', resource: 'products.thumbnail' }, - // 'products.image' is missing - { action: 'write', resource: 'products.panel.description' }, - { action: 'write', resource: 'products.panel.images' }, - // 'products.panel.stock' is missing - ]), -}; +import { + ArrayInput, + Edit, + DateInput, + SimpleFormIterator, + TextInput +} from 'react-admin'; +import { AccordionForm } from '@react-admin/ra-form-layout'; -const ProductEdit = () => ( +const CustomerEdit = () => ( - - - - - - - - - - + + + + - - + + + + + + + + - { /* delete button not displayed */ } ); ``` -{% endraw %} - -Check [the RBAC `` component](./AuthRBAC.md#accordionform) documentation for more details. diff --git a/docs/AuthRBAC.md b/docs/AuthRBAC.md index 5915288fb8b..a5fe36b877f 100644 --- a/docs/AuthRBAC.md +++ b/docs/AuthRBAC.md @@ -5,15 +5,19 @@ title: "RBAC" # Role-Based Access Control (RBAC) -React-admin Enterprise Edition contains [the ra-rbac module](https://react-admin-ee.marmelab.com/documentation/ra-rbac), which adds fine-grained permissions to your admin. This module extends the `authProvider` and adds replacement for many react-admin components that use these permissions. +Building up on react-admin's [Access Control features](./Permissions.md#access-control), react-admin RBAC provides an implementation for `authProvider.canAccess()` to manage roles and fine-grained permissions, and exports alternative react-admin [components](#components) that use these permissions. -Test it live in the [Enterprise Edition Storybook](https://storybook.ra-enterprise.marmelab.com/?path=/story/ra-rbac-full-app--full-app). +The RBAC features are part of [ra-rbac](https://react-admin-ee.marmelab.com/documentation/ra-rbac), an [Enterprise Edition](https://react-admin-ee.marmelab.com) package. Test them live in the [Enterprise Edition Storybook](https://react-admin.github.io/ra-enterprise/?path=/story/ra-rbac-full-app--full-app). -You can define permissions for pages, fields, buttons, etc. Roles and permissions are managed by the `authProvider`, which means you can use any data source you want (including an ActiveDirectory server). +## At a Glance + +RBAC relies on an array of roles and permissions to determine what a user can do in a React-admin application. You can define permissions for pages, fields, buttons, etc. These permissions use a serialization format that is easy to understand and to maintain. You can store them in a database, in a JSON file, or in your code. + +Roles and permissions are used by `authProvider.canAccess()` to provide fine-grained access control to the entire app. The above demo uses the following set of permissions: @@ -62,17 +66,19 @@ const roles = { ## Installation +First, install the `@react-admin/ra-rbac` package: + ``` npm install --save @react-admin/ra-rbac # or yarn add @react-admin/ra-rbac ``` -Make sure you [enable auth features](https://marmelab.com/react-admin/Authentication.html#enabling-auth-features) by setting an ``, and [disable anonymous access](https://marmelab.com/react-admin/Authentication.html#disabling-anonymous-access) by adding the `` prop. This will ensure that react-admin waits for the `authProvider` response before rendering anything. - **Tip**: ra-rbac is part of the [React-Admin Enterprise Edition](https://react-admin-ee.marmelab.com/), and hosted in a private npm registry. You need to subscribe to one of the Enterprise Edition plans to access this package. -## Vocabulary +Make sure you [enable auth features](https://marmelab.com/react-admin/Authentication.html#enabling-auth-features) by setting an ``, and [disable anonymous access](https://marmelab.com/react-admin/Authentication.html#disabling-anonymous-access) by adding the `` prop. This will ensure that react-admin waits for the `authProvider` response before rendering anything. + +## Concepts ### Permission @@ -88,6 +94,28 @@ Here are a few examples of permissions: **Tip**: When the `record` field is omitted, the permission is valid for all records. +### Action + +An _action_ is a string, usually a verb, that represents an operation. Examples of actions include "read", "create", "edit", "delete", or "export". + +React-admin already does page-level access control with actions like "list", "show", "edit", "create", and "delete". RBAC checks additional actions in its components: + +| Action | Description | Used In | +| -------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `list` | Allow to access the List page | [``](./List.md#access-control), [``](./Buttons.md#listbutton), [``](./Menu.md#access-control) | +| `show` | Allow to access the Show page | [``](./Show.md), [``](./Buttons.md#showbutton), [``](./Datagrid.md#access-control), [``](./Edit.md) | +| `create` | Allow to access the Create page | [``](./Create.md), [``](./Buttons.md#createbutton), [``](./List.md#access-control) | +| `edit` | Allow to access the Edit page | [``](./Edit.md), [``](./Buttons.md#editbutton), [``](./Datagrid.md#access-control), [``](./Show.md) | +| `delete` | Allow to delete data | [``](./Buttons.md#deletebutton), [``](./Buttons.md#bulkdeletebutton), [``](./Datagrid.md#access-control), [``](./SimpleForm.md#access-control), [``](#tabform) | +| `export` | Allow to export data | [``](./Buttons.md#exportbutton), [``](./List.md#access-control) | +| `clone` | Allow to clone a record | [``](./Buttons.md#clonebutton), [``](./Edit.md) | +| `read` | Allow to view a field (or a tab) | [``](./Datagrid.md#access-control), [``](./SimpleShowLayout.md#access-control), [``](./TabbedShowLayout.md#access-control) | +| `write` | Allow to edit a field (or a tab) | [``](./SimpleForm.md#access-control), [``](./TabbedForm.md#access-control), [``](./WizardForm.md#enableaccesscontrol), [``](./LongForm.md#enableaccesscontrol), [``](./AccordionForm.md#enableaccesscontrol) | + +**Tip:** Be sure not to confuse "show" and "read", or "edit" and "write", as they are not the same. The first operate at the page level, the second at the field level. A good mnemonic is to realize "show" and "edit" are named the same as the react-admin page they allow to control: the Show and Edit pages. + +You can also add your own actions, and use them in your own components using [`useCanAccess`](./useCanAccess.md) or [``](./CanAccess.md). + ### Role A *role* is a string that represents a responsibility. Examples of roles include "admin", "reader", "moderator", and "guest". A user can have one or more roles. @@ -134,37 +162,9 @@ const corrector123Role = [ **Tip**: The _order_ of permissions isn't significant. As soon as at least one permission grants access to an action on a resource, ra-rbac grant access to it - unless there is an [explicit deny](#explicit-deny). -The RBAC system relies on *permissions* only. It's the `authProvider`'s responsibility to map roles to permissions. See the [`authProvider` Methods](#authprovider-methods) section for details. - -### Action - -An _action_ is a string, usually a verb, that represents an operation. Examples of actions include "read", "create", "edit", "delete", or "export". - -Ra-rbac defines its own actions that you can use with ra-rbac components, but you can also define your own actions, and implement them in your own components using [`useCanAccess`](https://react-admin-ee.marmelab.com/documentation/ra-rbac#usecanaccess), [`canAccess`](https://react-admin-ee.marmelab.com/documentation/ra-rbac#canaccess) or [``](https://react-admin-ee.marmelab.com/documentation/ra-rbac#ifcanaccess). - -Ra-rbac's built-in actions operate at different levels: - -- **Page:** controls visibility of a page like the Edit page -- **Field:** controls visibility of a specific field, for example in a form -- **Action:** controls permission to perform global actions, like exporting data - -Here are all the actions supported by ra-rbac: - -| Action | Level | Description | Used In | -| -------- | ------ | -------------------------------- | --------------------------------------------------------------------------------------------------------------- | -| `list` | Page | Allow to access the List page | [``](#resource), [``](#menu) | -| `show` | Page | Allow to access the Show page | [``](#resource), [``](#datagrid), [``](#edit), [``](#show) | -| `create` | Page | Allow to access the Create page | [``](#resource), [``](#list) | -| `edit` | Page | Allow to access the Edit page | [``](#resource), [``](#datagrid), [``](#edit), [``](#show) | -| `export` | Action | Allow to export data | [``](#list) | -| `delete` | Action | Allow to delete data | [``](#datagrid), [``](#simpleform), [``](#tabform) | -| `clone` | Action | Allow to clone a record | [``](#edit) | -| `read` | Field | Allow to view a field (or a tab) | [``](#datagrid), [``](#simpleshowlayout), [``](#tabbedshowlayout) | -| `write` | Field | Allow to edit a field (or a tab) | [``](#simpleform), [``](#tabbedform) | - -**Tip:** Be sure not to confuse "show" and "read", or "edit" and "write", as they are not the same. The first operate at the page level, the second at the field level. A good mnemonic is to realize "show" and "edit" are named the same as the react-admin page they allow to control: the Show and Edit pages. +### Pessimistic Strategy -## Concepts +RBAC components treat permissions in a pessimistic way: while permissions are loading, react-admin doesn't render the components that require permissions, assuming that these components are restricted by default. It's only when the `authProvider.canAccess()` has resolved that RBAC components render. ### Principle Of Least Privilege @@ -181,14 +181,22 @@ By default, a permission applies to all records of a resource. A permission can be restricted to a specific record or a specific set of records. Setting the `record` field in a permission restricts the application of that permissions to records matching that criteria (using [lodash `isMatch`](https://lodash.com/docs/4.17.15#isMatch)). ```jsx -// can read all users, without record restriction -const perm1 = { action: "read", resource: "users" }; -// can write only user of id 123 -const perm2 = { action: "write", resource: "users", record: { "id": "123" } }; -// can access only comments by user of id 123 -const perm3 = { action: "*", resource: "comments", record: { "user_id": "123" } }; +// can view all users, without record restriction +const perm1 = { action: ['list', 'show'], resource: 'users' }; +const perm2 = { action: 'read', resource: 'users.*' }; +// can only edit field 'username' for user of id 123 +const perm4 = { action: 'write', resource: 'users.username', record: { id: '123' } }; ``` +Only record-level components can perform record-level permissions checks. Below is the list of components that support them: + +- [``](./SimpleShowLayout.md#access-control) +- [``](./TabbedShowLayout.md#access-control) +- [``](./SimpleForm.md#access-control) +- [``](./TabbedForm.md#access-control) + +When you restrict permissions to a specific set of records, components that do not support record-level permissions (such as List Components) will ignore the record criteria and perform their checks at the resource-level only. + ### Explicit Deny Some users may have access to all resources but one. Instead of having to list all the resources they have access to, you can use a special permission with the "deny" type that explicitly denies access to a resource. @@ -213,276 +221,206 @@ const allProductsButStock = [ **Tip**: Deny permissions are evaluated first, no matter in which order the permissions are defined. -## `authProvider` Methods +## Setup -Ra-rbac builds up on react-admin's `authProvider` API. It precises the return format of the `getPermissions()` method which must return a promise for an array of permissions objects. +Define role definitions in your application code, or fetch them from an API. ```jsx -const authProvider = { - // ... - getPermissions: () => Promise.resolve([ - { action: ["read", "write"], resource: "users", record: { "id": "123" } }, - ]) +export const roleDefinitions = { + admin: [ + { action: '*', resource: '*' } + ], + reader: [ + { action: ['list', 'show', 'export'], resource: '*' } + { action: 'read', resource: 'posts.*' } + { action: 'read', resource: 'comments.*' } + ], + accounting: [ + { action: '*', resource: 'sales' }, + ], }; ``` -For every restricted resource, ra-rbac calls `authProvider.getPermissions()` to get the permissions. - -In practice, most auth providers get the permissions as a response from the login query, and store these permissions in memory or localStorage. When a component calls `authProvider.getPermissions()`, the auth provider only needs to read from that local copy of the permissions. - -`authProvider.getPermissions()` doesn't return roles - only permissions. Usually, the role definitions are committed with the application code, as a constant. The roles of the current user are fetched at login, and the permissions are computed from the roles and the role definitions. - -You can use the `getPermissionsFromRoles` helper in the `authProvider` to compute the permissions that the user has based on their permissions. This function takes an object as argument with the following fields: - -- `roleDefinitions`: a static object containing the role definitions for each role -- `userRoles` _(optional)_: an array of roles (admin, reader...) for the current user -- `userPermissions` _(optional)_: an array of permissions for the current user, to be added to the permissions computed from the roles - -Here is an example `authProvider` implementation following this pattern: +The user roles and permissions should be returned upon login. The `authProvider` should store the permissions in memory, or in localStorage. This allows `authProvider.canAccess()` to read the permissions from localStorage. ```tsx import { getPermissionsFromRoles } from '@react-admin/ra-rbac'; +import { roleDefinitions } from './roleDefinitions'; -const roleDefinitions = { - admin: [{ action: '*', resource: '*' }], - reader: [{ action: 'read', resource: '*' }], -}; const authProvider = { - login: ({ username, password }) => { + login: async ({ username, password }) => { const request = new Request('https://mydomain.com/authenticate', { method: 'POST', body: JSON.stringify({ username, password }), headers: new Headers({ 'Content-Type': 'application/json' }), }); - return fetch(request) - .then(response => { - if (response.status < 200 || response.status >= 300) { - throw new Error(response.statusText); - } - return response.json(); - }) - .then(data => { - // data is like - // { - // "id": 123, - // "fullName": "John Doe", - // "permissions": [ - // { action: ["read", "write"], resource: "users", record: { id: "123" } }, - // ] - // "roles": ["admin", "reader"], - // } - const permissions = getPermissionsFromRoles({ - roleDefinitions, - userPermissions: data.permissions, - userRoles: data.roles, - }); - localStorage.setItem( - 'permissions', - JSON.stringify(permissions) - ); - }); + const response = await fetch(request); + if (response.status < 200 || response.status >= 300) { + throw new Error(response.statusText); + } + const { user: { roles, permissions }} = await response.json(); + // merge the permissions from the roles with the extra permissions + const permissions = getPermissionsFromRoles({ + roleDefinitions, + userPermissions, + userRoles + }); + localStorage.setItem('permissions', JSON.stringify(permissions)); }, // ... - getPermissions: () => { - const permissions = JSON.parse(localStorage.getItem('permissions')); - return Promise.resolve(permissions); - }, }; ``` -**Tip**: If you have to rely on the server for roles and permissions, check out [the Performance section](#performance) below. - -## Hooks - -Ra-rbac provides hooks to enable or disable features based on roles and permissions. - -- [`usePermissions()`](./usePermissions.md) returns the current permissions. -- [`useCanAccess()`](https://react-admin-ee.marmelab.com/documentation/ra-rbac#usecanaccess) returns a boolean indicating whether the user has access to the given resource. - -## Components +Then, use these permissions in `authProvider.canAccess()`: -Ra-rbac provides alternative components to react-admin base components. These alternative components include role-based access control and are as follows: - -- Main - - [``](#resource) - - [``](#menu) -- List - - [``](#list) - - [``](#listactions) - - [``](#datagrid) -- Detail - - [``](#edit) - - [``](#show) - - [``](#simpleshowlayout) - - [``](#tab) -- Form - - [``](#simpleform) - - [``](#tabbedform) - - [``](#formtab) - - [``](#accordionform) - - [``](#accordionsection) - - [``](#longform) - - [``](#wizardform) +```tsx +import { canAccessWithPermissions } from '@react-admin/ra-rbac'; -## `` +const authProvider = { + // ... + canAccess: async ({ resource, action, record }) => { + const permissions = JSON.parse(localStorage.getItem('permissions')); + // check if the user can access the resource and action + return canAccessWithPermissions({ permissions, resource, action, record }); + }, +}; +``` -Alternative to react-admin's [``](https://marmelab.com/react-admin/AccordionForm.html) that adds RBAC control to the accordions, the inputs, and the delete button. +**Tip**: If `canAccess` needs to call the server every time, check out [the Performance section](#performance) below. -This component is provided by the `@react-admin/ra-enterprise` package. +## `getPermissionsFromRoles` -{% raw %} -```tsx -import { Edit, TextInput } from 'react-admin'; -import { AccordionForm } from '@react-admin/ra-enterprise'; +This function returns an array of user permissions based on a role definition, a list of roles, and a list of user permissions. It merges the permissions defined in `roleDefinitions` for the current user's roles (`userRoles`) with the extra `userPermissions`. -const authProvider = { - // ... - getPermissions: () => Promise.resolve([ - { action: ['list', 'edit'], resource: 'products' }, - { action: 'write', resource: 'products.reference' }, - { action: 'write', resource: 'products.width' }, - { action: 'write', resource: 'products.height' }, - // 'products.description' is missing - { action: 'write', resource: 'products.thumbnail' }, - // 'products.image' is missing - // note that the panel with the name 'description' will be displayed - { action: 'write', resource: 'products.panel.description' }, - // note that the panel with the name 'images' will be displayed - { action: 'write', resource: 'products.panel.images' }, - // 'products.panel.stock' is missing - ]), +```jsx +// static role definitions (usually in the app code) +const roleDefinitions = { + admin: [ + { action: '*', resource: '*' } + ], + reader: [ + { action: ['list', 'show', 'export'], resource: '*' } + { action: 'read', resource: 'posts.*' } + { action: 'read', resource: 'comments.*' } + ], + accounting: [ + { action: '*', resource: 'sales' }, + ], }; -const ProductEdit = () => ( - - - - - - - {/* not displayed */} - - - - {/* not displayed */} - - - - {/* not displayed */} - - - - {/* delete button not displayed */} - - -); +const permissions = getPermissionsFromRoles({ + roleDefinitions, + // roles of the current user (usually returned by the server upon login) + userRoles: ['reader'], + // extra permissions for the current user (usually returned by the server upon login) + userPermissions: [ + { action: 'list', resource: 'sales'}, + ], +}); +// permissions = [ +// { action: ['list', 'show', 'export'], resource: '*' }, +// { action: 'read', resource: 'posts.*' }, +// { action: 'read', resource: 'comments.*' }, +// { action: 'list', resource: 'sales' }, +// ]; ``` -{% endraw %} -**Tip**: You must add a `name` prop to the `` so you can reference it in the permissions. -Then, to allow users to access a particular ``, update the permissions definition as follows: `{ action: 'write', resource: '{RESOURCE}.panel.{NAME}' }`, where `RESOURCE` is the resource name, and `NAME` the name you provided to the ``. +This function takes an object as argument with the following fields: -For instance, to allow users access to the following tab `` in `products` resource, add this line in permissions: `{ action: 'write', resource: 'products.panel.description' }`. - -`` also only renders the child inputs for which the user has the 'write' permissions. +- `roleDefinitions`: a dictionary containing the role definition for each role +- `userRoles` _(optional)_: an array of roles (admin, reader...) for the current user +- `userPermissions` _(optional)_: an array of permissions for the current user -## `` +## `canAccessWithPermissions` -Replacement for the default `` that only renders a section if the user has the right permissions. `` also only renders the child inputs for which the user has the 'write' permissions. This component is provided by the `@react-admin/ra-enterprise` package. +`canAccessWithPermissions` is a helper that facilitates the `authProvider.canAccess()` method implementation: -{% raw %} ```tsx -import { Edit, SimpleForm, TextInput } from 'react-admin'; -import { AccordionSection } from '@react-admin/ra-enterprise'; +import { canAccessWithPermissions } from '@react-admin/ra-rbac'; const authProvider = { // ... - getPermissions: () => Promise.resolve([ - { action: ['list', 'edit'], resource: 'products' }, - { action: 'write', resource: 'products.reference' }, - { action: 'write', resource: 'products.width' }, - { action: 'write', resource: 'products.height' }, - // 'products.description' is missing - { action: 'write', resource: 'products.thumbnail' }, - // 'products.image' is missing - // note that the section with the name 'description' will be displayed - { action: 'write', resource: 'products.section.description' }, - // note that the section with the name 'images' will be displayed - { action: 'write', resource: 'products.section.images' }, - // 'products.section.stock' is missing - ]), + canAccess: async ({ action, resource, record }) => { + const permissions = JSON.parse(localStorage.getItem('permissions')); + return canAccessWithPermissions({ + permissions, + action, + resource, + record, + }); + } }; - -const ProductEdit = () => ( - - - - - - - // not displayed - - - - // not displayed - - - - // not displayed - - - - - -); ``` -{% endraw %} +`canAccessWithPermissions` expects the `permissions` to be a flat array of permissions. It is your responsibility to fetch these permissions (usually during login). If the permissions are spread into several role definitions, you can merge them into a single array using the [`getPermissionsFromRoles`](#getpermissionsfromroles) function. -Add a `name` prop to the `` so you can reference it in the permissions. -Then, to allow users to access a particular ``, update the permissions definition as follows: `{ action: 'write', resource: '{RESOURCE}.section.{NAME}' }`, where `RESOURCE` is the resource name, and `NAME` the name you provided to the ``. +## Components -For instance, to allow users access to the following tab `` in `products` resource, add this line in permissions: `{ action: 'write', resource: 'products.section.description' }`. +Ra-rbac provides alternative components to react-admin base components with RBAC support: -## `` +- Main + - [``](./Menu.md#access-control) +- List + - [``](./List.md#access-control) + - [``](./Datagrid.md#access-control) + - [``](./Buttons.md#exportbutton) +- Detail + - [``](./SimpleShowLayout.md#access-control) + - [``](./TabbedShowLayout.md#access-control) + - [``](./Buttons.md#clonebutton) +- Form + - [``](./SimpleForm.md#access-control) + - [``](./TabbedForm.md#access-control) -Alternative to react-admin's `` that adds RBAC control to columns +In addition, the following components from te Enterprise edition have built-in RBAC support: -To see a column, the user must have the permission to read the resource column: +- [``](./AccordionForm.md#access-control) +- [``](./LongForm.md#access-control) +- [``](./WizardForm.md#access-control) -```jsx -{ action: "read", resource: `${resource}.${source}` } -``` +Here is an example of `` with RBAC: -Also, the `rowClick` prop is automatically set depending on the user props: - -- "edit" if the user has the permission to edit the resource -- "show" if the user doesn't have the permission to edit the resource but has the permission to show it -- empty otherwise - -```jsx -import { ImageField, TextField, ReferenceField, NumberField } from 'react-admin'; -import { List, Datagrid } from '@react-admin/ra-rbac'; +```tsx +import { + canAccessWithPermissions, + List, + Datagrid +} from '@react-admin/ra-rbac'; +import { + ImageField, + TextField, + ReferenceField, + NumberField, +} from 'react-admin'; -const authProvider= { +const authProvider = { // ... - getPermissions: () => Promise.resolve({ - permissions: [ - { action: "list", resource: "products" }, - { action: "read", resource: "products.thumbnail" }, - { action: "read", resource: "products.reference" }, - { action: "read", resource: "products.category_id" }, - { action: "read", resource: "products.width" }, - { action: "read", resource: "products.height" }, - { action: "read", resource: "products.price" }, - { action: "read", resource: "products.description" }, - ] - }), + canAccess: async ({ action, record, resource }) => + canAccessWithPermissions({ + permissions: [ + { action: 'list', resource: 'products' }, + { action: 'read', resource: 'products.thumbnail' }, + { action: 'read', resource: 'products.reference' }, + { action: 'read', resource: 'products.category_id' }, + { action: 'read', resource: 'products.width' }, + { action: 'read', resource: 'products.height' }, + { action: 'read', resource: 'products.price' }, + { action: 'read', resource: 'products.description' }, + // { action: 'read', resource: 'products.stock' }, + // { action: 'read', resource: 'products.sales' }, + // { action: 'delete', resource: 'products' }, + { action: 'show', resource: 'products' }, + ], + action, + record, + resource + }), }; const ProductList = () => ( - {/* ra-rbac Datagrid */} + {/* The datagrid has no bulk actions as the user doesn't have the 'delete' permission */} + @@ -492,7 +430,7 @@ const ProductList = () => ( - {/* these two columns are not visible to the user */} + {/** These two columns are not visible to the user **/} @@ -500,584 +438,51 @@ const ProductList = () => ( ); ``` -## `` - -Replacement for react-admin's `` that adds RBAC control to actions. - -- Users must have the 'show' permission on the resource and record to see the ``. -- Users must have the 'clone' permission on the resource and record to see the ``. - -```jsx -import { Edit } from '@react-admin/ra-rbac'; - -const authProvider = { - // ... - getPermissions: () => Promise.resolve({ - permissions: [ - { action: ['list', 'edit', 'clone'], resource: 'products' }, - ], - }), -}; - -export const PostEdit = () => ( - - ... - -); -// user will see the clone button but not the show button -``` - -## `` - -Replacement for react-admin's `` that adds RBAC control to actions and bulk actions. - -- Users must have the 'create' permission on the resource to see the ``. -- Users must have the 'export' permission on the resource to see the `` and the ``. -- Users must have the 'delete' permission on the resource to see the ``. - -```jsx -import { List } from '@react-admin/ra-rbac'; - -const authProvider = { - // ... - getPermissions: () => Promise.resolve({ - permissions: [ - { action: 'list', resource: 'products' }, - { action: 'create', resource: 'products' }, - { action: 'delete', resource: 'products' }, - // action 'export' is missing - ], - }), -}; - -export const PostList = () => ( - - ... - -); -// user will see the following actions on top of the list: -// - create -// user will see the following bulk actions upon selection: -// - delete -``` - -**Tip**: This `` component relies on [the `` component](#listactions) below. - -## `` - -Replacement for react-admin's `` that adds RBAC control to actions. - -- Users must have the 'create' permission on the resource to see the ``. -- Users must have the 'export' permission on the resource to see the ``. - -```jsx -import { List } from 'react-admin'; -import { ListActions } from '@react-admin/ra-rbac'; - -export const PostList = () => ( - }> - ... - -); -``` - -## `` - -Alternative to react-admin's [``](https://marmelab.com/react-admin/LongForm.html) that adds RBAC control to the delete button, hides sections users don't have access to, and renders inputs based on permissions. Part of the `@react-admin/ra-enterprise` package. - -{% raw %} -```tsx -import { LongForm } from '@react-admin/ra-enterprise'; - -const authProvider = { - // ... - getPermissions: () => Promise.resolve([ - { action: ['list', 'edit'], resource: 'products' }, - /* sections */ - { action: 'write', resource: 'products.section.description' }, - { action: 'write', resource: 'products.section.images' }, - // 'products.section.stock' is missing - - /* inputs */ - { action: 'write', resource: 'products.reference' }, - { action: 'write', resource: 'products.width' }, - { action: 'write', resource: 'products.height' }, - // 'products.description' is missing - // 'products.image' is missing - { action: 'write', resource: 'products.thumbnail' }, - ]), -}; - -const ProductEdit = () => ( - - - - - - - {/* not displayed */} - - - - {/* not displayed */} - - - - {/* not displayed */} - - - - {/* delete button not displayed */} - - -); -``` - -{% endraw %} - -**Tip**: You must add a `name` prop to the `` so you can reference it in the permissions. -Then, to allow users to access a particular ``, update the permissions definition as follows: `{ action: 'write', resource: '{RESOURCE}.section.{NAME}' }`, where `RESOURCE` is the resource name, and `NAME` the name you provided to the ``. - -For instance, to allow users access to the following tab `` in `products` resource, add this line in permissions: `{ action: 'write', resource: 'products.section.description' }`. - -`` also only renders the child inputs for which the user has the 'write' permissions. - -## `` - -A replacement for react-admin's `` component, which only displays the menu items that the current user has access to (using the `list` action). - -Pass this menu to a ``, and pass that layout to the `` component to use it. - -```jsx -import { Admin, Resource, ListGuesser, Layout, LayoutProps } from 'react-admin'; -import { Menu } from '@react-admin/ra-rbac'; - -import * as posts from './posts'; -import * as comments from './comments'; -import * as users from './users'; - -import dataProvider from './dataProvider'; -const authProvider= { - // ... - getPermissions: () => Promise.resolve({ - permissions: [ - { action: "*", resource: "posts" }, - { action: "*", resource: "comments" }, - ] - }), -}; - -const CustomLayout = ({ children }) => ( - - {children} - -); - -const App = () => ( - - - - {/* the user won't see the Users menu */} - - -); -``` - -## `` - -To restrict access to Create, Edit, List and Show views for your resources, use the `` component from ra-rbac rather than the one from react-admin: - -```jsx -import { Admin } from 'react-admin'; -import { Resource } from '@react-admin/ra-rbac'; -import { UserList, UserEdit, UserShow, UserCreate } from './users'; -import { CommentList, CommentEdit, CommentCreate, CommentShow } from './comments'; - -import dataProvider from './dataProvider'; -import authProvider from './authProvider'; - -const App = () => ( - - - - -); -``` - -Ra-rbac's `` relies on the following actions: - -- `list` to enable the list view -- `show` to enable the show view -- `create` to enable the create view -- `edit` to enable the edit view - -**Tip**: When using ra-rbac's ``, the `permissions` injected to ``, `` and `` component are the merged permissions of the user and the user's roles (as returned by `usePermission`). This makes the use of `canAccess` more straightforward. Here is the Datagrid example from the `canAccess` section above, revisited for an application using ra-rbac's ``: - -```jsx -import { List, Datagrid, TextField } from 'react-admin'; -import { usePermissions, canAccess } from '@react-admin/ra-rbac'; - -const authProvider = { - checkAuth: () => Promise.resolve(), - login: () => Promise.resolve(), - logout: () => Promise.resolve(), - checkError: () => Promise.resolve(), - getPermissions: () => Promise.resolve({ - permissions: [ - { action: ['list', 'read_price'], resource: 'products' }, - ], - }), -}; - -const ProductList = () => { - const { permissions } = usePermissions(); - return ( - - - - - - - {canAccess({ - permissions, - action: 'read_price', - resource: 'products', - }) && } - {/* this column will not render */} - {canAccess({ - permissions, - action: 'read_stock', - resource: 'products', - }) && } - - - ); -} -``` - -## `` - -Replacement for react-admin's `` that adds RBAC control to actions. - -Users must have the 'edit' permission on the resource and record to see the ``. - -```jsx -import { ShowProps } from 'react-admin'; -import { Show } from '@react-admin/ra-rbac'; - -const authProvider = { - // ... - getPermissions: () => Promise.resolve({ - permissions: [ - { action: ['list', 'show', 'edit'], resource: 'products' }, - ], - }), -}; - -export const PostShow = () => ( - - ... - -); -// user will see the edit action on top of the Show view -``` - -To control the appearance of individual fields, use [the `` component](#simpleshowlayout) from ra-enterprise. - -## `` - -Alternative to react-admin's `` that adds RBAC control to inputs - -To see an input, the user must have the permission to write the resource field: - -```js -{ action: "write", resource: `${resource}.${source}` } -``` - -`` also renders the delete button only if the user has the 'delete' permission. - -```jsx -import { Edit, TextInput } from 'react-admin'; -import { SimpleForm } from '@react-admin/ra-rbac'; - -const authProvider= { - // ... - getPermissions: () => Promise.resolve({ - permissions: [ - // 'delete' is missing - { action: ['list', 'edit'], resource: 'products' }, - { action: 'write', resource: 'products.reference' }, - { action: 'write', resource: 'products.width' }, - { action: 'write', resource: 'products.height' }, - // 'products.description' is missing - { action: 'write', resource: 'products.thumbnail' }, - // 'products.image' is missing - ] - }), -}; - -const ProductEdit = () => ( - - - - - - {/* not displayed */} - - {/* not displayed */} - - - {/* no delete button */} - - -); -``` - -## `` - -Alternative to react-admin's `` that adds RBAC control to fields - -To see a column, the user must have the permission to read the resource column: - -```js -{ action: "read", resource: `${resource}.${source}` } -``` - -```jsx -import { ShowProps } from 'react-admin'; -import { SimpleShowLayout } from '@react-admin/ra-rbac'; - -const authProvider= { - // ... - getPermissions: () => Promise.resolve({ - permissions: [ - { action: ['list', 'show'], resource: 'products' }, - { action: 'read', resource: 'products.reference' }, - { action: 'read', resource: 'products.width' }, - { action: 'read', resource: 'products.height' }, - // 'products.description' is missing - // 'products.image' is missing - { action: 'read', resource: 'products.thumbnail' }, - // 'products.stock' is missing - ] - }), -}; - -const ProductShow = () => ( - - {/* <-- RBAC SimpleShowLayout */} - - - - {/* not displayed */} - - {/* not displayed */} - - - {/* not displayed */} - - - -); -``` - -## `` - -`` shows only the tabs for which users have read permissions, using the `[resource].tab.[tabName]` string as resource identifier. `` shows only the child fields for which users have the read permissions, using the `[resource].[source]` string as resource identifier. - -```jsx -import { Show, TextField } from 'react-admin'; -import { TabbedShowLayout } from '@react-admin/ra-rbac'; - -const authProvider = { - // ... - getPermissions: () => Promise.resolve([ - // crud - { action: ['list', 'show'], resource: 'products' }, - // tabs ('products.tab.stock' is missing) - { action: 'read', resource: 'products.tab.description' }, - { action: 'read', resource: 'products.tab.images' }, - // fields ('products.description' and 'products.image' are missing) - { action: 'read', resource: 'products.reference' }, - { action: 'read', resource: 'products.width' }, - { action: 'read', resource: 'products.height' }, - { action: 'read', resource: 'products.thumbnail' }, - ]), -}; - -const ProductShow = () => ( - - - - - - - {/* the description field is not displayed */} - - - {/* the stock tab is not displayed */} - - - - - {/* the images field is not displayed */} - - - - - -); -``` - -You must add a `name` prop to the `` so you can reference it in the permissions. -Then, to allow users to access a particular ``, update the permissions definition as follows: `{ action: 'read', resource: '{RESOURCE}.tab.{NAME}' }`, where `RESOURCE` is the resource name, and `NAME` the name you provided to the ``. - -For instance, to allow users access to the following tab `` in `products` resource, add this line in permissions: `{ action: 'read', resource: 'products.tab.description' }`. - -## `` - -`` shows only the tabs for which users have write permissions, using the `[resource].tab.[tabName]` string as resource identifier. It also renders the delete button only if the user has a permission for the `delete` action in the current resource. `` shows only the child inputs for which users have the write permissions, using the `[resource].[source]` string as resource identifier. - - -```tsx -import { Edit, TextInput } from 'react-admin'; -import { TabbedForm } from '@react-admin/ra-rbac'; - -const authProvider = { - // ... - getPermissions: () => Promise.resolve([ - // crud (the delete action is missing) - { action: ['list', 'edit'], resource: 'products' }, - // tabs ('products.tab.stock' is missing) - { action: 'write', resource: 'products.tab.description' }, - { action: 'write', resource: 'products.tab.images' }, - // fields ('products.description' and 'products.image' are missing) - { action: 'write', resource: 'products.reference' }, - { action: 'write', resource: 'products.width' }, - { action: 'write', resource: 'products.height' }, - { action: 'write', resource: 'products.thumbnail' }, - ]), -}; - -const ProductEdit = () => ( - - - - - - - {/* the description input is not displayed */} - - - {/* the stock tab is not displayed */} - - - - - {/* the images input is not displayed */} - - - - {/* the delete button is not displayed */} - - -); -``` - -You must add a `name` prop to the `` so you can reference it in the permissions. Then, to allow users to access a particular ``, update the permissions definition as follows: `{ action: 'write', resource: '{RESOURCE}.tab.{NAME}' }`, where `RESOURCE` is the resource name, and `NAME` the name you provided to the ``. - -For instance, to allow users access to the following tab `` in `products` resource, add this line in permissions: `{ action: 'write', resource: 'products.tab.description' }`. - -## `` - -Alternative to react-admin's `` that adds RBAC control to hide steps users don't have access to. `` also only renders the child inputs for which the user has the 'write' permissions. - -This component is provided by the `@react-admin/ra-enterprise` package. - -{% raw %} -```tsx -import { WizardForm } from '@react-admin/ra-enterprise'; - -const authProvider = { - checkAuth: () => Promise.resolve(), - login: () => Promise.resolve(), - logout: () => Promise.resolve(), - checkError: () => Promise.resolve(), - getPermissions: () =>Promise.resolve([ - // 'delete' is missing - { action: ['list', 'edit'], resource: 'products' }, - { action: 'write', resource: 'products.reference' }, - { action: 'write', resource: 'products.width' }, - { action: 'write', resource: 'products.height' }, - // 'products.description' is missing - { action: 'write', resource: 'products.thumbnail' }, - // 'products.image' is missing - // note that the step with the name 'description' will be displayed - { action: 'write', resource: 'products.step.description' }, - // note that the step with the name 'images' will be displayed - { action: 'write', resource: 'products.step.images' }, - // 'products.step.stock' is missing - ]), -}; - -const ProductCreate = () => ( - - - - - - - {/* Won't be displayed */} - - - - {/* Won't be displayed */} - - - - {/* Won't be displayed */} - - - - {/* Delete button won't be displayed */} - - -); -``` -{% endraw %} - -**Tip**: You must add a `name` prop to the `` so you can reference it in the permissions. -Then, to allow users to access a particular ``, update the permissions definition as follows: `{ action: 'write', resource: '{RESOURCE}.step.{NAME}' }`, where `RESOURCE` is the resource name, and `NAME` the name you provided to the ``. - -For instance, to allow users access to the following tab `` in `products` resource, add this line in permissions: `{ action: 'write', resource: 'products.step.description' }`. - ## Performance -`authProvider.getPermissions()` can return a promise, which in theory allows to rely on the authentication server for permissions. The downside is that this slows down the app a great deal, as each page may contain dozens of calls to these methods. - -To compensate for that, `usePermissions` uses a stale-while-revalidate approach, and after the initial call to `authProvider.getPermissions()`, it will return the permissions from the cache, and refresh them in the background. +`authProvider.canAccess()` can return a promise, which in theory allows to rely on the authentication server for permissions. The downside is that this slows down the app a great deal, as each page may contain dozens of calls to these methods. In practice, your `authProvider` should use short-lived sessions, and refresh the permissions only when the session ends. JSON Web tokens (JWT) work that way. Here is an example of an `authProvider` that stores the permissions in memory, and refreshes them only every 5 minutes: -```jsx +```tsx +import { canAccessWithPermissions, getPermissionsFromRoles } from '@react-admin/ra-rbac'; + let permissions; // memory cache let permissionsExpiresAt = 0; const getPermissions = () => { const request = new Request('https://mydomain.com/permissions', { - headers: new Headers({ 'Authorization': `Bearer ${localStorage.getItem('token')}` }), + headers: new Headers({ + Authorization: `Bearer ${localStorage.getItem('token')}`, + }), + }); + return fetch(request) + .then(res => resp.json()) + .then(data => { + permissions = data.permissions; + permissionsExpiresAt = Date.now() + 1000 * 60 * 5; // 5 minutes }); - return fetch(request) - .then(res => resp.json()) - .then(data => { - permissions = data.permissions; - permissionsExpiresAt = Date.now() + 1000 * 60 * 5; // 5 minutes - }); -} +}; + +let roleDefinitions; // memory cache +let rolesExpiresAt = 0; +const getRoles = () => { + const request = new Request('https://mydomain.com/roles', { + headers: new Headers({ + Authorization: `Bearer ${localStorage.getItem('token')}`, + }), + }); + return fetch(request) + .then(res => resp.json()) + .then(data => { + roleDefinitions = data.roles; + rolesExpiresAt = Date.now() + 1000 * 60 * 5; // 5 minutes + }); +}; const authProvider = { - login: ({ username, password }) => { + login: ({ username, password }) => { const request = new Request('https://mydomain.com/authenticate', { method: 'POST', body: JSON.stringify({ username, password }), @@ -1092,11 +497,27 @@ const authProvider = { }) .then(data => { localStorage.setItem('token', JSON.stringify(data.token)); + localStorage.setItem('userRoles', JSON.stringify(data.roles)); }); }, // ... - getPermissions: () => { - return Date.now() > permissionsExpiresAt ? getPermissions() : permissions; + canAccess: async ({ action, record, resource }) => { + if (Date.now() > rolesExpiresAt) { + await getRoles(); + } + if (Date.now() > permissionsExpiresAt) { + await getPermissions(); + } + return canAccessWithPermissions({ + permissions: getPermissionsFromRoles({ + roleDefinitions, + userPermissions: permissions, + userRoles: localStorage.getItem('userRoles'), + }, + action, + record, + resource, + }); }, }; ``` diff --git a/docs/Buttons.md b/docs/Buttons.md index d8777e7ce33..d0e83496ee6 100644 --- a/docs/Buttons.md +++ b/docs/Buttons.md @@ -568,6 +568,80 @@ To override the style of all instances of ` + + + + + ); +}; + +const AutocompleteInputWithCreate = props => { + const inputRef = React.useRef(null); + return ( + { + setTimeout(() => { + inputRef.current?.select(); + }, 50); + }, + }} + create={} + /> + ); +}; + +const StyledAutocompleteInput = styled(AutocompleteInput)({ + '& .MuiTextField-root': { + margin: '1px 0px', + }, + '& .MuiTextField-root fieldset': { + border: 'none', + }, + '& .MuiTextField-root input': { + fontSize: 14, + }, + '& .MuiInputLabel-root': { + display: 'none', + }, +}); + +const StyledListbox = styled('ul')({ + fontSize: 14, +}); + +export const CommentListWithAutocompleteWithCreate = () => { + const columnDefs = [ + // ... + { + field: 'post_id', + cellEditor: ( + + + + ), + cellEditorParams: { + submitOnChange: true, + noThemeOverride: true, // prevent the default theme override + }, + }, + ]; + return ( + + + + ); +}; +``` +{% endraw %} + ### Using AG Grid Enterprise `` is also compatible with the [Enterprise version of ag-grid](https://www.ag-grid.com/react-data-grid/licensing/). @@ -1468,16 +1806,17 @@ The client-side performance isn't affected by a large number of records, as ag-g | Prop | Required | Type | Default | Description | | ------------------- | -------- | --------------------------- | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -| `columnDefs` | Required | Array | n/a | The columns definitions | | `bulkActionButtons` | Optional | Element | `` | The component used to render the bulk action buttons | +| `cellEditor` | Optional | String, Function or Element | | Allows to use a custom component to render the cell editor | | `cellRenderer` | Optional | String, Function or Element | | Allows to use a custom component to render the cell content | +| `columnDefs` | Required | Array | n/a | The columns definitions | | `darkTheme` | Optional | String | `'ag-theme-alpine-dark'` | The name of the ag-grid dark theme | | `defaultColDef` | Optional | Object | | The default column definition (applied to all columns) | | `mutationOptions` | Optional | Object | | The mutation options | +| `pagination` | Optional | Boolean | `true` | Enable or disable pagination | | `preferenceKey` | Optional | String or `false` | `${resource}.ag-grid.params` | The key used to persist [`gridState`](https://www.ag-grid.com/react-data-grid/grid-state/) in the Store. `false` disables persistence. | | `sx` | Optional | Object | | The sx prop passed down to the wrapping `
` element | | `theme` | Optional | String | `'ag-theme-alpine'` | The name of the ag-grid theme | -| `pagination` | Optional | Boolean | `true` | Enable or disable pagination | `` also accepts the same props as [``](https://www.ag-grid.com/react-data-grid/grid-options/) with the exception of `rowData`, since the data is fetched from the List context. @@ -1527,6 +1866,146 @@ export const PostList = () => { ``` {% endraw %} +### `cellEditor` + +In a column definition, you can use the `cellEditor` field to specify a custom cell editor. You can use any [Edit Component](https://www.ag-grid.com/react-data-grid/cell-editors/) supported by `ag-grid`, including [Custom Components](https://www.ag-grid.com/react-data-grid/cell-editors/#custom-components). + +In addition to that, `` supports using [React Admin inputs](./Inputs.md) as `cellEditor`, such as [``](./TextInput.md) or even [``](./ReferenceInput.md). + +This allows to leverage all the power of react-admin inputs in your grid, for example to edit a reference. + +To use a React Admin input as `cellEditor`, you need to pass it as a *React Element*: + +```tsx +import { List, ReferenceInput } from 'react-admin'; +import { DatagridAGClient } from '@react-admin/ra-datagrid-ag'; + +export const CommentList = () => { + const columnDefs = [ + // ... + { + field: 'post_id', + cellEditor: ( + + ), + }, + ]; + return ( + + + + ); +}; +``` + + + +If you are passing a React Admin input as *React Element*, there are two additional props you can use: `submitOnChange` and `noThemeOverride`. + +These props need to be passed as `cellEditorParams`. + +`submitOnChange` allows to submit the change to ag-grid as soon as the input value changes, without waiting for the user to submit the form (e.g. by pressing Enter or clicking outside the cell). + +This provides a better UX for example with components such as `` or ``, as the value is immediately updated after the user selects an option. + +```tsx +import { List, ReferenceInput } from 'react-admin'; +import { DatagridAGClient } from '@react-admin/ra-datagrid-ag'; + +export const CommentList = () => { + const columnDefs = [ + // ... + { + field: 'post_id', + cellEditor: ( + + ), + cellEditorParams: { + submitOnChange: true, + }, + }, + ]; + return ( + + + + ); +}; +``` + +`noThemeOverride` allows to prevent `DatagridAGClient` from applying custom styles to the input. + +Indeed, `DatagridAGClient` applies custom styles to the inputs to make them look like ag-grid cells. However, this can cause issues for instance when rendering a `Dialog` containing additional inputs inside the cell editor. This can happen, for example, if you are using a custom create component with ``. + +To solve this issue, you can set `noThemeOverride` to `true` and apply your own styles to the input component. + +```tsx +import { styled } from '@mui/material'; +import { List, ReferenceInput, AutocompleteInput } from 'react-admin'; +import { DatagridAGClient } from '@react-admin/ra-datagrid-ag'; +import { CreatePostDialog } from './CreatePostDialog'; + +export const CommentList = () => { + const columnDefs = [ + // ... + { + field: 'post_id', + cellEditor: ( + + + + ), + cellEditorParams: { + noThemeOverride: true, + }, + }, + ]; + return ( + + + + ); +}; + +const AutocompleteInputWithCreate = () => { + return ( + } + /> + ); +}; + +const StyledAutocompleteInput = styled(AutocompleteInput)({ + '& .MuiTextField-root': { + margin: '1px 0px', + }, + '& .MuiTextField-root fieldset': { + border: 'none', + }, + '& .MuiTextField-root input': { + fontSize: 14, + }, + '& .MuiInputLabel-root': { + display: 'none', + }, +}); + +const StyledListbox = styled('ul')({ + fontSize: 14, +}); +``` + +**Tip:** Be sure to read the [Fine Tuning Input Components Used As Cell Editor](#fine-tuning-input-components-used-as-cell-editor) section to improve the UX of your custom cell editors. + +**Tip:** Using a custom `cellEditor` works great in combination with a custom [`cellRenderer`](#cellrenderer-1). + +**Note:** React Admin inputs used ad `cellEditor` do not (yet) support form validation. + ### `cellRenderer` In a column definition, you can use the `cellRenderer` field to specify a custom cell renderer. In addition to [ag-grid's cell rendering abilities](https://www.ag-grid.com/react-data-grid/cell-rendering/), `` supports [react-admin fields](./Fields.md) in `cellRenderer`. This is particularly useful to render a [``](./ReferenceField.md) for instance. @@ -1573,6 +2052,8 @@ export const CommentList = () => { **Note:** You still need to pass the `source` prop to the field. +**Tip:** This works great in combination with a custom [`cellEditor`](#celleditor-1). + ### `columnDefs` The `columnDefs` prop is the most important prop of ``. It defines the columns of the grid, and their properties. It is an array of objects, each object representing a column. diff --git a/docs/Edit.md b/docs/Edit.md index d12288e55f4..7b44f9906ce 100644 --- a/docs/Edit.md +++ b/docs/Edit.md @@ -1034,10 +1034,20 @@ export const BookEdit = () => { }; ``` -## Security +## Anonymous Access The `` component requires authentication and will redirect anonymous users to the login page. If you want to allow anonymous access, use the [`disableAuthentication`](#disableauthentication) prop. +```jsx +const PostEdit = () => ( + + ... + +); +``` + +## Access Control + If your `authProvider` implements [Access Control](./Permissions.md#access-control), `` will only render if the user has the "edit" access to the related resource. For instance, for the ``page below: @@ -1065,4 +1075,4 @@ const PostEdit = () => ( Users without access will be redirected to the [Access Denied page](./Admin.md#accessdenied). -**Note**: Access control is disabled when you use [the `disableAuthentication` prop](#disableauthentication). \ No newline at end of file +**Note**: Access control is disabled when you use [the `disableAuthentication` prop](#disableauthentication). diff --git a/docs/List.md b/docs/List.md index 9849282192b..363bf227542 100644 --- a/docs/List.md +++ b/docs/List.md @@ -673,6 +673,22 @@ const CommentList = () => ( **Tip**: You may also remove the `` by passing `false` to the `exporter` prop: `exporter={false}` +**Tip**: If you need to use [RBAC](./AuthRBAC.md) to hide some columns based on user permissions, you can use `useExporterWithAccessControl` to apply access control to the exported records: + +```tsx +import { List, useExporterWithAccessControl } from '@ra-enterprise/ra-rbac'; +import { myExporter } from './myExporter'; + +export const PostList = () => { + const exporter = useExporterWithAccessControl({ exporter: myExporter }) + return ( + + {/*...*/} + + ); +} +``` + **Tip**: Looking for an ``? React-admin doesn't provide this feature, but the community has an excellent third-party module for CSV import: [benwinding/react-admin-import-csv](https://github.com/benwinding/react-admin-import-csv). ## `filters`: Filter Inputs @@ -1354,13 +1370,25 @@ const ProductList = () => { `useListController` returns callbacks to sort, filter, and paginate the list, so you can build a complete List page. Check [the `useListController`hook documentation](./useListController.md) for details. -## Security +## Anonymous Access The `` component requires authentication and will redirect anonymous users to the login page. If you want to allow anonymous access, use the [`disableAuthentication`](#disableauthentication) prop. -If your `authProvider` implements [Access Control](./Permissions.md#access-control), `` will only render if the user has the "list" access to the related resource. +```jsx +import { List } from 'react-admin'; + +const BoolkList = () => ( + + ... + +); +``` + +## Access Control -For instance, for the `` page below: +If your `authProvider` implements [Access Control](./Permissions.md#access-control), `` will only render if the user can access the resource with the "list" action. + +For instance, to render the `` page below: ```tsx import { List, Datagrid, TextField } from 'react-admin'; @@ -1385,4 +1413,73 @@ const PostList = () => ( Users without access will be redirected to the [Access Denied page](./Admin.md#accessdenied). -**Note**: Access control is disabled when you use [the `disableAuthentication` prop](#disableauthentication). \ No newline at end of file +**Note**: Access control is disabled when you use [the `disableAuthentication` prop](#disableauthentication). + +For finer access control of the list action buttons, use the `` component from the `@react-admin/ra-rbac` package. + +```diff +-import { List } from 'react-admin'; ++import { List } from '@react-admin/ra-rbac'; +``` + +This component adds the following [RBAC](./AuthRBAC.md) controls: + +- Users must have the `'create'` permission on the resource to see the ``. +- Users must have the `'export'` permission on the resource to see the ``. +- Users must have the `'read'` permission on a resource column to see it in the export: + +```jsx +{ action: "read", resource: `${resource}.${source}` }. +// +{ action: "read", resource: `${resource}.*` }. +``` + +Here is an example of `` with RBAC: + +```tsx +import { List } from '@react-admin/ra-rbac'; + +const authProvider = { + // ... + canAccess: async () => + canAccessWithPermissions({ + permissions: [ + { action: 'list', resource: 'products' }, + { action: 'export', resource: 'products' }, + // actions 'create' and 'delete' are missing + { action: 'read', resource: 'products.name' }, + { action: 'read', resource: 'products.description' }, + { action: 'read', resource: 'products.price' }, + { action: 'read', resource: 'products.category' }, + // resource 'products.stock' is missing + ], + action, + resource, + record + }), +}; + +export const PostList = () => ( + + {/*...*/} + +); +// Users will see the Export action on top of the list, but not the Create action. +// Users will only see the authorized columns when clicking on the export button. +``` + +**Tip**: If you need a custom [`exporter`](#exporter), you can use `useExporterWithAccessControl` to apply access control to the exported records: + +```tsx +import { List, useExporterWithAccessControl } from '@ra-enterprise/ra-rbac'; +import { myExporter } from './myExporter'; + +export const PostList = () => { + const exporter = useExporterWithAccessControl({ exporter: myExporter }) + return ( + + {/*...*/} + + ); +} +``` diff --git a/docs/LongForm.md b/docs/LongForm.md index f1129a54f80..dc06115dd83 100644 --- a/docs/LongForm.md +++ b/docs/LongForm.md @@ -88,9 +88,12 @@ Here are all the props you can set on the `` component: | Prop | Required | Type | Default | Description | | ------------------------ | -------- | ----------------- | ------- | ---------------------------------------------------------- | +| `authorizationError` | Optional | `ReactNode` | `null` | The content to display when authorization checks fail | | `children` | Required | `ReactNode` | - | A list of `` elements. | | `defaultValues` | Optional | `object|function` | - | The default values of the record. | +| `enableAccessControl` | Optional | `boolean` | `false` | Enable checking authorization rights for each section and input | | `id` | Optional | `string` | - | The id of the underlying `
` tag. | +| `loading` | Optional | `ReactNode` | | The content to display when checking authorizations | | `noValidate` | Optional | `boolean` | - | Set to `true` to disable the browser's default validation. | | `onSubmit` | Optional | `function` | `save` | A callback to call when the form is submitted. | | `sanitizeEmptyValues` | Optional | `boolean` | - | Set to `true` to remove empty values from the form state. | @@ -102,6 +105,48 @@ Here are all the props you can set on the `` component: Additional props are passed to `react-hook-form`'s [`useForm` hook](https://react-hook-form.com/docs/useform). +## `authorizationError` + +Used when `enableAccessControl` is set to `true` and an error occurs while checking for users permissions. Defaults to `null`: + +{% raw %} +```tsx +import { ArrayInput, Edit, DateInput, SimpleFormIterator, TextInput } from 'react-admin'; +import { LongForm } from '@react-admin/ra-form-layout'; +import { Alert } from '@mui/material'; + +const CustomerEdit = () => ( + + + An error occurred while loading your permissions + + } + > + + + + + + + + + + + + + + + +); +``` +{% endraw %} + ## `children` The children of `` must be [`` elements](#longformsection). @@ -149,6 +194,51 @@ export const PostCreate = () => ( **Tip**: React-admin also allows to define default values at the input level. See the [Setting default Values](./Forms.md#default-values) section. +## `enableAccessControl` + +When set to `true`, React-admin will call the `authProvider.canAccess` method for each section with the following parameters: +- `action`: `write` +- `resource`: `RESOURCE_NAME.section.SECTION_ID_OR_LABEL`. For instance: `customers.section.identity` +- `record`: The current record + +For each section, react-admin will also call the `authProvider.canAccess` method for each input with the following parameters: +- `action`: `write` +- `resource`: `RESOURCE_NAME.INPUT_SOURCE`. For instance: `customers.first_name` +- `record`: The current record + +**Tip**: `` direct children that don't have a `source` will always be displayed. + +```tsx +import { + ArrayInput, + Edit, + DateInput, + SimpleFormIterator, + TextInput +} from 'react-admin'; +import { LongForm } from '@react-admin/ra-form-layout'; + +const CustomerEdit = () => ( + + + + + + + + + + + + + + + + + +); +``` + ## `id` Normally, a submit button only works when placed inside a `` tag. However, you can place a submit button outside the form if the submit button `form` matches the form `id`. @@ -170,6 +260,43 @@ export const PostCreate = () => ( ); ``` +## `loading` + +Used when `enableAccessControl` is set to `true` while checking for users permissions. Defaults to `Loading` from `react-admin`: + +```tsx +import { ArrayInput, Edit, DateInput, SimpleFormIterator, TextInput } from 'react-admin'; +import { LongForm } from '@react-admin/ra-form-layout'; +import { Typography } from '@mui/material'; + +const CustomerEdit = () => ( + + + Loading your permissions... + + } + > + + + + + + + + + + + + + + + +); +``` + ## `noValidate` The `` attribute prevents the browser from validating the form. This is useful if you don't want to use the browser's default validation, or if you want to customize the error messages. To set this attribute on the underlying `` tag, set the `noValidate` prop to `true`. @@ -363,10 +490,52 @@ It accepts the following props: | Prop | Required | Type | Default | Description | | ----------------- | -------- | ----------- | ------- | ------------------------------------------------------------------------------------ | -| `label` | Required | `string` | - | The main label used as the section title. Appears in red when the section has errors | -| `children` | Required | `ReactNode` | - | A list of `` elements | -| `cardinality` | Optional | `number` | - | A number to be displayed next to the label in TOC, to quantify it | -| `sx` | Optional | `object` | - | An object containing the Material UI style overrides to apply to the root component | +| `authorizationError` | Optional | `ReactNode` | - | The content to display when authorization checks fail | +| `cardinality` | Optional | `number` | - | A number to be displayed next to the label in TOC, to quantify it | +| `children` | Required | `ReactNode` | - | A list of `` elements | +| `enableAccessControl` | Optional | `ReactNode` | - | Enable authorization checks | +| `label` | Required | `string` | - | The main label used as the section title. Appears in red when the section has errors | +| `loading` | Optional | `ReactNode` | - | The content to display while checking authorizations | +| `sx` | Optional | `object` | - | An object containing the Material UI style overrides to apply to the root component | + +#### `authorizationError` + +Used when `enableAccessControl` is set to `true` and an error occurs while checking for users permissions. Defaults to `null`: + +{% raw %} +```tsx +import { ArrayInput, Edit, DateInput, SimpleFormIterator, TextInput } from 'react-admin'; +import { LongForm } from '@react-admin/ra-form-layout'; +import { Alert } from '@mui/material'; + +const CustomerEdit = () => ( + + + + + + + + An error occurred while loading your permissions + + }> + + + + + + + + + + +); +``` +{% endraw %} ### `cardinality` @@ -428,6 +597,78 @@ const CustomerEditWithCardinality = () => { }; ``` +### `enableAccessControl` + +When set to `true`, react-admin will also call the `authProvider.canAccess` method for each input with the following parameters: +- `action`: `write` +- `resource`: `RESOURCE_NAME.INPUT_SOURCE`. For instance: `customers.first_name` +- `record`: The current record + +**Tip**: `` direct children that don't have a `source` will always be displayed. + +```tsx +import { ArrayInput, Edit, DateInput, SimpleFormIterator, TextInput } from 'react-admin'; +import { LongForm } from '@react-admin/ra-form-layout'; + +const CustomerEdit = () => ( + + + + + + + + + + + + + + + + + +); +``` + +### `loading` + +Used when `enableAccessControl` is set to `true` while checking for users permissions. Defaults to `Loading` from `react-admin`: + +```tsx +import { ArrayInput, Edit, DateInput, SimpleFormIterator, TextInput } from 'react-admin'; +import { LongForm } from '@react-admin/ra-form-layout'; +import { Typography } from '@mui/material'; + +const CustomerEdit = () => ( + + + + Loading your permissions... + + }> + + + + + Loading your permissions... + + }> + + + + + + + + + + +); +``` + ## AutoSave In forms where users may spend a lot of time, it's a good idea to save the form automatically after a few seconds of inactivity. You turn on this feature by using [the `` component](./AutoSave.md). @@ -466,59 +707,43 @@ const PersonEdit = () => ( Note that you **must** set the `` prop to `{ keepDirtyValues: true }`. If you forget that prop, any change entered by the end user after the autosave but before its acknowledgement by the server will be lost. -If you're using it in an `` page, you must also use a `pessimistic` or `optimistic` [`mutationMode`](https://marmelab.com/react-admin/Edit.html#mutationmode) - `` doesn't work with the default `mutationMode="undoable"`. +If you're using it in an `` page, you must also use a `pessimistic` or `optimistic` [`mutationMode`](./Edit.md#mutationmode) - `` doesn't work with the default `mutationMode="undoable"`. Check [the `` component](./AutoSave.md) documentation for more details. -## Role-Based Access Control (RBAC) +## Access Control -Fine-grained permissions control can be added by using the [``](./AuthRBAC.md#longform) and [``](./AuthRBAC.md#longform) components provided by the `@react-admin/ra-enterprise` package. +`` can use [Access Control](./AccessControl.md) to check permissions for each section and input. To enable this feature, set the `enableAccessControl` prop to `true`. + +Check the [`enableAccessControl` prop](#enableaccesscontrol) section for more details. -{% raw %} ```tsx -import { LongForm } from '@react-admin/ra-enterprise'; - -const authProvider = { - checkAuth: () => Promise.resolve(), - login: () => Promise.resolve(), - logout: () => Promise.resolve(), - checkError: () => Promise.resolve(), - getPermissions: () =>Promise.resolve([ - // 'delete' is missing - { action: ['list', 'edit'], resource: 'products' }, - { action: 'write', resource: 'products.reference' }, - { action: 'write', resource: 'products.width' }, - { action: 'write', resource: 'products.height' }, - // 'products.description' is missing - { action: 'write', resource: 'products.thumbnail' }, - // 'products.image' is missing - { action: 'write', resource: 'products.Section.description' }, - { action: 'write', resource: 'products.Section.images' }, - // 'products.Section.stock' is missing - ]), -}; +import { + ArrayInput, + Edit, + DateInput, + SimpleFormIterator, + TextInput +} from 'react-admin'; +import { LongForm } from '@react-admin/ra-form-layout'; -const ProductEdit = () => ( +const CustomerEdit = () => ( - - - - - - - - - - + + + + - - + + + + + + + + - // delete button not displayed ); ``` -{% endraw %} - -Check [the RBAC ``](./AuthRBAC.md#longform) documentation for more details. diff --git a/docs/Menu.md b/docs/Menu.md index 4b51cd09472..14104f6bacc 100644 --- a/docs/Menu.md +++ b/docs/Menu.md @@ -465,3 +465,35 @@ const MyReactAdmin = () => ( ``` ![MenuLive](./img/MenuLive.png) + +## Access Control + +If you `authProvider` supports [Access Control](./Permissions.md#access-control), the `` component will use it to only render the `` for which the user has the `list` permission. + +If you want to add access control to custom menu items, use the `` component from the `@react-admin/ra-rbac` package. + +```diff +-import { Menu } from 'react-admin'; ++import { Menu } from '@react-admin/ra-rbac'; +``` + +This Menu component only display a `` if the user has access to the specified `action` and `resource`. + +```tsx +import { Menu } from '@react-admin/ra-rbac'; + +export const MyMenu = () => ( + + + {/* This menu item will render only if the user has 'list' access to the 'products' resource */} + + {/* This menu item will render for all users */} + + +); +``` diff --git a/docs/Permissions.md b/docs/Permissions.md index e85552efc08..50799c5e4f6 100644 --- a/docs/Permissions.md +++ b/docs/Permissions.md @@ -171,7 +171,7 @@ If the current user tries to access a page they don't have access to, they are r If the `authProvider.canAccess()` method returns an error, the user is redirected to an "Access Control Error" page. You can customize this page by adding a custom route on the `/accessControlError` path. -The **action buttons** (``, ``, ``, ``, and ``) also have built-in access control. They are only displayed if the user can access the corresponding action on the resource. +The **action buttons** (``, ``, ``, ``, and ``) also have built-in access control. They are only displayed if the user can access the corresponding action on the resource. ```tsx const MyToolbar = () => ( @@ -190,6 +190,8 @@ const MyToolbar = () => ( ); ``` +The **list components** (``), **show components** (``, ``), and **edit components** (``, ``) also support access control provided you use the version from the `@react-admin/ra-rbac` Enterprise package. Check the [RBAC documentation](./AuthRBAC.md#components) for more information. + ### `useCanAccess` If you need to control access on mount in your own components, use the `useCanAccess()` hook. Since `authProvider.canAccess()` is asynchronous, the hook returns an object with an `isPending` property set to `true` until the promise resolves. Make sure you don't use the result until `isPending` is `false`. diff --git a/docs/Resource.md b/docs/Resource.md index 6f55d60e97d..83330adef82 100644 --- a/docs/Resource.md +++ b/docs/Resource.md @@ -321,25 +321,6 @@ const MyComponent = () => ( ); ``` -## Security - -The usual components for the `` routes ( ``, ``, ``, ``) require authentication and will redirect anonymous users to the login page. If you want to allow anonymous access, use the [`disableAuthentication`](./List.md#disableauthentication) prop on the component. - -In addition, if your `authProvider` implements [Access Control](./Permissions.md#access-control), these components will only render if the user has the right permission (e.g., `{ action: 'list', resource: 'posts' }` for the `list` page of the `posts` resource). - -For instance, given the following resource: - -```tsx - -``` - -React-admin will call the `authProvider.canAccess` method when users try to access the pages with the following parameters: - -- For the list page: `{ action: "list", resource: "posts" }` -- For the create page: `{ action: "create", resource: "posts" }` -- For the edit page: `{ action: "edit", resource: "posts" }` -- For the show page: `{ action: "show", resource: "posts" }` - ## Nested Resources React-admin doesn't support nested resources, but you can use [the `children` prop](#children) to render a custom component for a given sub-route. For instance, to display a list of songs for a given artist: @@ -464,3 +445,29 @@ When users navigate to the `/posts` route, react-admin will display a loading in ![Loading indicator](./img/lazy-resource.png) +## Anonymous Access + +The usual components for the `` routes ( ``, ``, ``, ``) require authentication and will redirect anonymous users to the login page. If you want to allow anonymous access, use the [`disableAuthentication`](./List.md#disableauthentication) prop on the component. + +## Access Control + +In addition, if your `authProvider` implements [Access Control](./Permissions.md#access-control), these components will only render if the user has the right permission (e.g., `{ action: 'list', resource: 'posts' }` for the `list` page of the `posts` resource). + +For instance, given the following resource: + +```tsx + +``` + +React-admin will call the `authProvider.canAccess` method when users try to access the pages with the following parameters: + +- For the list page: `{ action: "list", resource: "posts" }` +- For the create page: `{ action: "create", resource: "posts" }` +- For the edit page: `{ action: "edit", resource: "posts" }` +- For the show page: `{ action: "show", resource: "posts" }` diff --git a/docs/Show.md b/docs/Show.md index fbdecae1f28..dd735632b87 100644 --- a/docs/Show.md +++ b/docs/Show.md @@ -701,10 +701,20 @@ export const BookShow = () => { }; ``` -## Security +## Anonymous Access The `` component requires authentication and will redirect anonymous users to the login page. If you want to allow anonymous access, use the [`disableAuthentication`](#disableauthentication) prop. +```jsx +const PostShow = () => ( + + ... + +); +``` + +## Access Control + If your `authProvider` implements [Access Control](./Permissions.md#access-control), `` will only render if the user has the "show" access to the related resource. For instance, for the ``page below: diff --git a/docs/ShowBase.md b/docs/ShowBase.md index 977c4a637f3..9b5360837e7 100644 --- a/docs/ShowBase.md +++ b/docs/ShowBase.md @@ -81,7 +81,7 @@ import { Grid } from '@mui/material'; import StarIcon from '@mui/icons-material/Star'; const BookShow = () => ( - + @@ -117,20 +117,6 @@ const PostShow = () => ( ); ``` -## `emptyWhileLoading` - -By default, `` renders its child component even before the `dataProvider.getOne()` call returns. If you use `` or ``, this isn't a problem as these components only render when the record has been fetched. But if you use a custom child component that expects the record context to be defined, your component will throw an error. - -The `` prop provides a convenient shortcut for that use case. When enabled, `` won't render its child until the record is loaded. - -```jsx -const BookShow = () => ( - - ... - -); -``` - ## `id` By default, `` deduces the identifier of the record to show from the URL path. So under the `/posts/123/show` path, the `id` prop will be `123`. You may want to force a different identifier. In this case, pass a custom `id` prop. diff --git a/docs/SimpleForm.md b/docs/SimpleForm.md index 5f6b1a26fe4..be244675175 100644 --- a/docs/SimpleForm.md +++ b/docs/SimpleForm.md @@ -512,29 +512,6 @@ const { isDirty } = useFormState(); // ✅ const formState = useFormState(); // ❌ should deconstruct the formState ``` -## Displaying Inputs Based On Permissions - -You can leverage [the `usePermissions` hook](./usePermissions.md) to display inputs if the user has the required permissions. - -{% raw %} -```jsx -import { usePermissions, Create, SimpleForm, TextInput } from 'react-admin'; - -export const UserCreate = () => { - const { permissions } = useGetPermissions(); - return ( - - - - {permissions === 'admin' && - } - - - ); -} -``` -{% endraw %} - ## Configurable You can let end users customize the fields displayed in the `` by using the `` component instead. @@ -647,51 +624,6 @@ If you're using it in an `` page, you must also use a `pessimistic` or `op Check [the `` component](./AutoSave.md) documentation for more details. -## Role-Based Access Control (RBAC) - -Fine-grained permissions control can be added by using the [``](./AuthRBAC.md#simpleform) component provided by the `@react-admin/ra-rbac` package. - -{% raw %} -```jsx -import { Edit, TextInput } from 'react-admin'; -import { SimpleForm } from '@react-admin/ra-rbac'; - -const authProvider= { - // ... - getPermissions: () => Promise.resolve({ - permissions: [ - // 'delete' is missing - { action: ['list', 'edit'], resource: 'products' }, - { action: 'write', resource: 'products.reference' }, - { action: 'write', resource: 'products.width' }, - { action: 'write', resource: 'products.height' }, - // 'products.description' is missing - { action: 'write', resource: 'products.thumbnail' }, - // 'products.image' is missing - ] - }), -}; - -const ProductEdit = () => ( - - - - - - {/* not displayed */} - - {/* not displayed */} - - - {/* no delete button */} - - -); -``` -{% endraw %} - -Check [the RBAC `` component](./AuthRBAC.md#simpleform) documentation for more details. - ## Versioning By default, `` updates the current record (via `dataProvider.update()`), so the previous version of the record is lost. If you want to keep the previous version, you can use the [``](https://react-admin-ee.marmelab.com/documentation/ra-history#simpleformwithrevision) component instead: @@ -829,3 +761,65 @@ export const PostCreate = () => ( ``` React-admin forms leverage react-hook-form's [`useForm` hook](https://react-hook-form.com/docs/useform). + +## Access Control + +If you need to hide some inputs based on a set of permissions, use the `` component from the `@react-admin/ra-rbac` package. + +```diff +-import { SimpleForm } from 'react-admin'; ++import { SimpleForm } from '@react-admin/ra-rbac'; +``` + +This component adds the following [RBAC](./AuthRBAC.md) controls: + +- To see an input, the user must have the 'write' permission on the resource field: + +```jsx +{ action: "write", resource: `${resource}.${source}` } +``` + +- The delete button only renders if the user has the 'delete' permission. + +Here is an example of how to use the `` component with RBAC: + +```tsx +import { Edit, TextInput } from 'react-admin'; +import { SimpleForm } from '@react-admin/ra-rbac'; + +const authProvider = { + // ... + canAccess: async ({ action, record, resource }) => + canAccessWithPermissions({ + permissions: [ + // 'delete' is missing + { action: ['list', 'edit'], resource: 'products' }, + { action: 'write', resource: 'products.reference' }, + { action: 'write', resource: 'products.width' }, + { action: 'write', resource: 'products.height' }, + // 'products.description' is missing + { action: 'write', resource: 'products.thumbnail' }, + // 'products.image' is missing + ] + action, + record, + resource, + }), +}; + +const ProductEdit = () => ( + + + + + + {/* not displayed */} + + {/* not displayed */} + + + {/* no delete button */} + + +); +``` diff --git a/docs/SimpleShowLayout.md b/docs/SimpleShowLayout.md index 7ff14f68f11..a0a0350e23c 100644 --- a/docs/SimpleShowLayout.md +++ b/docs/SimpleShowLayout.md @@ -261,6 +261,69 @@ const PostShow = () => ( ); ``` +## Access Control + +If you need to hide some fields based on a set of permissions, use the `` component from the `@react-admin/ra-rbac` package. + +```diff +-import { SimpleShowLayout } from 'react-admin'; ++import { SimpleShowLayout } from '@react-admin/ra-rbac'; +``` + +This component adds the following [RBAC](./AuthRBAC.md) controls: + +- To see a column, the user must have the "read" permission on the resource column: + +```jsx +{ action: "read", resource: `${resource}.${source}` } +// Or +{ action: "read", resource: `${resource}.*` } +``` + +Here is an example of how to use the `` component with RBAC: + +```tsx +import { SimpleShowLayout } from '@react-admin/ra-rbac'; + +const authProvider = { + // ... + canAccess: async ({ action, record, resource }) => + canAccessWithPermissions({ + permissions: [ + { action: ['list', 'show'], resource: 'products' }, + { action: 'read', resource: 'products.reference' }, + { action: 'read', resource: 'products.width' }, + { action: 'read', resource: 'products.height' }, + // 'products.description' is missing + // 'products.image' is missing + { action: 'read', resource: 'products.thumbnail' }, + // 'products.stock' is missing + ], + action, + record, + resource, + }), +}; + +const ProductShow = () => ( + + + {/* └── RBAC SimpleShowLayout */} + + + + {/* not displayed */} + + {/* not displayed */} + + + {/* not displayed */} + + + +); +``` + ## See Also * [Field components](./Fields.md) diff --git a/docs/TabbedForm.md b/docs/TabbedForm.md index 45a0443c501..2c1bbe1cc8c 100644 --- a/docs/TabbedForm.md +++ b/docs/TabbedForm.md @@ -794,88 +794,6 @@ If you're using it in an `` page, you must also use a `pessimistic` or `op Check [the `` component](./AutoSave.md) documentation for more details. -## Displaying a Tab Based On Permissions - -You can leverage [the `usePermissions` hook](./usePermissions.md) to display a tab only if the user has the required permissions. - -{% raw %} -```jsx -import { usePermissions, Edit, TabbedForm, FormTab } from 'react-admin'; - -const UserEdit = () => { - const { permissions } = usePermissions(); - return ( - - - - ... - - {permissions === 'admin' && - - ... - - } - - - ); -}; -``` -{% endraw %} - -## Role-Based Access Control (RBAC) - -You can show or hide tabs and inputs based on user permissions by using the [``](./AuthRBAC.md#tabbedform) component from the `@react-admin/ra-rbac` package instead of the `react-admin` package. - -[``](./AuthRBAC.md#tabbedform) shows only the tabs for which users have write permissions, using the `[resource].tab.[tabName]` string as resource identifier. It also renders the delete button only if the user has a permission for the `delete` action in the current resource. `` shows only the child inputs for which users have the write permissions, using the `[resource].[source]` string as resource identifier. - -{% raw %} -```tsx -import { Edit, TextInput } from 'react-admin'; -import { TabbedForm } from '@react-admin/ra-rbac'; - -const authProvider = { - // ... - getPermissions: () => Promise.resolve([ - // crud (the delete action is missing) - { action: ['list', 'edit'], resource: 'products' }, - // tabs ('products.tab.stock' is missing) - { action: 'write', resource: 'products.tab.description' }, - { action: 'write', resource: 'products.tab.images' }, - // fields ('products.description' and 'products.image' are missing) - { action: 'write', resource: 'products.reference' }, - { action: 'write', resource: 'products.width' }, - { action: 'write', resource: 'products.height' }, - { action: 'write', resource: 'products.thumbnail' }, - ]), -}; - -const ProductEdit = () => ( - - - - - - - {/* the description input is not displayed */} - - - {/* the stock tab is not displayed */} - - - - - {/* the images input is not displayed */} - - - - {/* the delete button is not displayed */} - - -); -``` -{% endraw %} - -Check [the RBAC `` component](./AuthRBAC.md#tabbedform) documentation for more details. ## Versioning @@ -956,3 +874,114 @@ export default OrderEdit; ``` **Tip:** If you'd like to avoid creating an intermediate component like ``, or are using an ``, you can use the [``](./Inputs.md#linking-two-inputs) component as an alternative. + +## Access Control + +If you need to hide some tabs based on a set of permissions, use the `` component from the `@react-admin/ra-rbac` package. + +```diff +-import { TabbedForm } from 'react-admin'; ++import { TabbedForm } from '@react-admin/ra-rbac'; +``` + +Use in conjunction with [``](#tabbedformtab) and add a `name` prop to the `Tab` to define the resource on which the user needs to have the 'write' permissions for. + +```jsx +import { Edit, TextInput } from 'react-admin'; +import { TabbedForm } from '@react-admin/ra-rbac'; + +const authProvider = { + // ... + canAccess: async ({ action, record, resource }) => + canAccessWithPermissions({ + permissions: [ + // action 'delete' is missing + { action: ['list', 'edit'], resource: 'products' }, + { action: 'write', resource: 'products.reference' }, + { action: 'write', resource: 'products.width' }, + { action: 'write', resource: 'products.height' }, + { action: 'write', resource: 'products.thumbnail' }, + { action: 'write', resource: 'products.tab.description' }, + // tab 'stock' is missing + { action: 'write', resource: 'products.tab.images' }, + ], + action, + record, + resource, + }), +}; + +const ProductEdit = () => ( + + + + + + + + + {/* the "Stock" tab is not displayed */} + + + + + + + + {/* the "Delete" button is not displayed */} + + +); +``` + +[``](#tabbedformtab) also renders only the child inputs for which the user has the 'write' permissions. + +```tsx +import { Edit, TextInput } from 'react-admin'; +import { TabbedForm } from '@react-admin/ra-rbac'; + +const authProvider = { + // ... + canAccess: async ({ action, record, resource }) => + canAccessWithPermissions({ + permissions: [ + { action: ['list', 'edit'], resource: 'products' }, + { action: 'write', resource: 'products.reference' }, + { action: 'write', resource: 'products.width' }, + { action: 'write', resource: 'products.height' }, + // 'products.description' is missing + { action: 'write', resource: 'products.thumbnail' }, + // 'products.image' is missing + { action: 'write', resource: 'products.tab.description' }, + // 'products.tab.stock' is missing + { action: 'write', resource: 'products.tab.images' }, + ], + action, + record, + resource, + }) +}; + +const ProductEdit = () => ( + + + + + + + {/* Input Description is not displayed */} + + + {/* Input Stock is not displayed */} + + + + + {/* Input Image is not displayed */} + + + + + +); +``` \ No newline at end of file diff --git a/docs/TabbedShowLayout.md b/docs/TabbedShowLayout.md index 211f732c8fa..0ec3fcd4e7e 100644 --- a/docs/TabbedShowLayout.md +++ b/docs/TabbedShowLayout.md @@ -249,7 +249,6 @@ const PostShow = () => ( ); ``` - The `` uses the humanized source by default. You can customize it by passing a `label` prop to the fields: ```jsx @@ -345,31 +344,39 @@ const StaticPostShow = () => ( When passed a `record`, `` creates a `RecordContext` with the given record. -## Role-Based Access Control (RBAC) +## Access Control -You can show or hide tabs and inputs based on user permissions by using the [``](./AuthRBAC.md#tabbedshowlayout) component from the `@react-admin/ra-rbac` package instead of the `react-admin` package. +If you need to hide some tabs based on a set of permissions, use the `` component from the `@react-admin/ra-rbac` package. -[``](./AuthRBAC.md#tabbedshowlayout) shows only the tabs for which users have read permissions, using the `[resource].tab.[tabName]` string as resource identifier. `` shows only the child fields for which users have the read permissions, using the `[resource].[source]` string as resource identifier. +```diff +-import { TabbedShowLayout } from 'react-admin'; ++import { TabbedShowLayout } from '@react-admin/ra-rbac'; +``` + +Use it in conjunction with [``](#tabbedshowlayouttab) and add a `name` prop to the `Tab` to define the resource on which the user needs to have the 'read' permissions for. -{% raw %} ```tsx import { Show, TextField } from 'react-admin'; import { TabbedShowLayout } from '@react-admin/ra-rbac'; const authProvider = { // ... - getPermissions: () => Promise.resolve([ - // crud - { action: ['list', 'show'], resource: 'products' }, - // tabs ('products.tab.stock' is missing) - { action: 'read', resource: 'products.tab.description' }, - { action: 'read', resource: 'products.tab.images' }, - // fields ('products.description' and 'products.image' are missing) - { action: 'read', resource: 'products.reference' }, - { action: 'read', resource: 'products.width' }, - { action: 'read', resource: 'products.height' }, - { action: 'read', resource: 'products.thumbnail' }, - ]), + canAccess: async ({ action, record, resource }) => + canAccessWithPermissions({ + permissions: [ + { action: ['list', 'show'], resource: 'products' }, + { action: 'read', resource: 'products.tab.description' }, + // { action: 'read', resource: 'products.tab.stock' }, + { action: 'read', resource: 'products.tab.images' }, + { action: 'read', resource: 'products.reference' }, + { action: 'read', resource: 'products.width' }, + { action: 'read', resource: 'products.height' }, + { action: 'read', resource: 'products.thumbnail' }, + ], + action, + record, + resource, + }), }; const ProductShow = () => ( @@ -379,15 +386,13 @@ const ProductShow = () => ( - {/* the description field is not displayed */} - {/* the stock tab is not displayed */} + {/* This tab is not displayed for the user */} - {/* the images field is not displayed */} @@ -395,10 +400,58 @@ const ProductShow = () => ( ); ``` -{% endraw %} -Check [the RBAC `` component](./AuthRBAC.md#tabbedshowlayout) documentation for more details. +[``](#tabbedshowlayouttab) also renders only the child fields for which the user has the 'read' permissions. + +```tsx +import { Show, TextField } from 'react-admin'; +import { TabbedShowLayout } from '@react-admin/ra-rbac'; +const authProvider = { + // ... + canAccess: async ({ action, record, resource }) => + canAccessWithPermissions({ + permissions: [ + { action: ['list', 'show'], resource: 'products' }, + { action: 'read', resource: 'products.reference' }, + { action: 'read', resource: 'products.width' }, + { action: 'read', resource: 'products.height' }, + // 'products.description' is missing + { action: 'read', resource: 'products.thumbnail' }, + // 'products.image' is missing + { action: 'read', resource: 'products.tab.description' }, + // 'products.tab.stock' is missing + { action: 'read', resource: 'products.tab.images' }, + ], + action, + record, + resource, + }), +}; + +const ProductShow = () => ( + + + + + + + {/* Field Description is not displayed */} + + + {/* Tab Stock is not displayed */} + + + + + {/* Field Image is not displayed */} + + + + + +); +``` ## See Also diff --git a/docs/WizardForm.md b/docs/WizardForm.md index fe155440403..984ac309f36 100644 --- a/docs/WizardForm.md +++ b/docs/WizardForm.md @@ -49,9 +49,12 @@ The `` component accepts the following props: | Prop | Required | Type | Default | Description | | ------------------------ | -------- | ----------------- | ------- | ---------------------------------------------------------- | +| `authorizationError` | Optional | `ReactNode` | `null` | The content to display when authorization checks fail | | `children` | Required | `ReactNode` | - | A list of `` elements. | | `defaultValues` | Optional | `object|function` | - | The default values of the record. | +| `enableAccessControl` | Optional | `boolean` | `false` | Enable checking authorization rights for each panel and input | | `id` | Optional | `string` | - | The id of the underlying `` tag. | +| `loading` | Optional | `ReactNode` | | The content to display when checking authorizations | | `noValidate` | Optional | `boolean` | - | Set to `true` to disable the browser's default validation. | | `onSubmit` | Optional | `function` | `save` | A callback to call when the form is submitted. | | `progress` | Optional | `ReactElement` | - | A custom progress stepper element. | @@ -63,6 +66,49 @@ The `` component accepts the following props: Additional props are passed to `react-hook-form`'s [`useForm` hook](https://react-hook-form.com/docs/useform). +## `authorizationError` + +Used when `enableAccessControl` is set to `true` and an error occurs while checking for users permissions. Defaults to `null`: + + +{% raw %} +```tsx +import { ArrayInput, Edit, DateInput, SimpleFormIterator, TextInput } from 'react-admin'; +import { WizardForm } from '@react-admin/ra-form-layout'; +import { Alert } from '@mui/material'; + +const CustomerEdit = () => ( + + + An error occurred while loading your permissions + + } + > + + + + + + + + + + + + + + + +); +``` +{% endraw %} + ## `children` The children of `` must be `` elements. @@ -110,6 +156,51 @@ export const PostCreate = () => ( **Tip**: React-admin also allows to define default values at the input level. See the [Setting default Values](./forms.md#default-values) section. +## `enableAccessControl` + +When set to `true`, React-admin will call the `authProvider.canAccess` method for each panel with the following parameters: +- `action`: `write` +- `resource`: `RESOURCE_NAME.section.PANEL_ID_OR_LABEL`. For instance: `customers.section.identity` +- `record`: The current record + +For each panel, react-admin will also call the `authProvider.canAccess` method for each input with the following parameters: +- `action`: `write` +- `resource`: `RESOURCE_NAME.INPUT_SOURCE`. For instance: `customers.first_name` +- `record`: The current record + +**Tip**: `` direct children that don't have a `source` will always be displayed. + +```tsx +import { + ArrayInput, + Edit, + DateInput, + SimpleFormIterator, + TextInput +} from 'react-admin'; +import { WizardForm } from '@react-admin/ra-form-layout'; + +const CustomerEdit = () => ( + + + + + + + + + + + + + + + + + +); +``` + ## `id` Normally, a submit button only works when placed inside a `` tag. However, you can place a submit button outside the form if the submit button `form` matches the form `id`. @@ -131,6 +222,43 @@ export const PostCreate = () => ( ); ``` +## `loading` + +Used when `enableAccessControl` is set to `true` while checking for users permissions. Defaults to `Loading` from `react-admin`: + +```tsx +import { ArrayInput, Edit, DateInput, SimpleFormIterator, TextInput } from 'react-admin'; +import { WizardForm } from '@react-admin/ra-form-layout'; +import { Typography } from '@mui/material'; + +const CustomerEdit = () => ( + + + Loading your permissions... + + } + > + + + + + + + + + + + + + + + +); +``` + ## `noValidate` The `` attribute prevents the browser from validating the form. This is useful if you don't want to use the browser's default validation, or if you want to customize the error messages. To set this attribute on the underlying `` tag, set the `noValidate` prop to `true`. @@ -433,6 +561,132 @@ const PostCreate = () => ( ); ``` +The children of `` must be `` elements. + +### Props + + +| Prop | Required | Type | Default | Description | +| --------------------- | -------- | ----------- | ------- | ------------------------------------------------------------------------------------ | +| `authorizationError` | Optional | `ReactNode` | - | The content to display when authorization checks fail | +| `enableAccessControl` | Optional | `ReactNode` | - | Enable authorization checks | +| `label` | Required | `string` | - | The main label used as the step title. Appears in red when the section has errors | +| `loading` | Optional | `ReactNode` | - | The content to display while checking authorizations | +| `children` | Required | `ReactNode` | - | A list of `` elements | +| `sx` | Optional | `object` | - | An object containing the MUI style overrides to apply to the root component | + +### `authorizationError` + +Used when `enableAccessControl` is set to `true` and an error occurs while checking for users permissions. Defaults to `null`: + +{% raw %} +```tsx +import { ArrayInput, Edit, DateInput, SimpleFormIterator, TextInput } from 'react-admin'; +import { WizardForm } from '@react-admin/ra-form-layout'; +import { Alert } from '@mui/material'; + +const CustomerEdit = () => ( + + + + + + + + An error occurred while loading your permissions + + }> + + + + + + + + + + +); +``` +{% endraw %} + + +### `enableAccessControl` + +When set to `true`, react-admin will also call the `authProvider.canAccess` method for each input with the following parameters: +- `action`: `write` +- `resource`: `RESOURCE_NAME.INPUT_SOURCE`. For instance: `customers.first_name` +- `record`: The current record + +**Tip**: `` direct children that don't have a `source` will always be displayed. + +```tsx +import { ArrayInput, Edit, DateInput, SimpleFormIterator, TextInput } from 'react-admin'; +import { WizardForm } from '@react-admin/ra-form-layout'; + +const CustomerEdit = () => ( + + + + + + + + + + + + + + + + + +); +``` + +### `loading` + +Used when `enableAccessControl` is set to `true` while checking for users permissions. Defaults to `Loading` from `react-admin`: + +```tsx +import { ArrayInput, Edit, DateInput, SimpleFormIterator, TextInput } from 'react-admin'; +import { WizardForm } from '@react-admin/ra-form-layout'; +import { Typography } from '@mui/material'; + +const CustomerEdit = () => ( + + + + Loading your permissions... + + }> + + + + + Loading your permissions... + + }> + + + + + + + + + + +); +``` + ## Adding a Summary Final Step In order to add a final step with a summary of the form values before submit, you can leverage `react-hook-form` [`useWatch`](https://react-hook-form.com/docs/usewatch) hook: @@ -472,57 +726,39 @@ const PostCreate = () => ( ); ``` -## Role-Based Access Control (RBAC) +## Access Control -Fine-grained permissions control can be added by using the [``](./AuthRBAC.md#wizardform) and [``](./AuthRBAC.md#wizardform) components provided by the `@react-admin/ra-enterprise` package. +`` can use [Access Control](./AccessControl.md) to check permissions for each section and input. To enable this feature, set the `enableAccessControl` prop to `true`. + +Check the [`enableAccessControl` prop](#enableaccesscontrol) section for more details. -{% raw %} ```tsx -import { WizardForm } from '@react-admin/ra-enterprise'; - -const authProvider = { - checkAuth: () => Promise.resolve(), - login: () => Promise.resolve(), - logout: () => Promise.resolve(), - checkError: () => Promise.resolve(), - getPermissions: () =>Promise.resolve([ - // 'delete' is missing - { action: ['list', 'edit'], resource: 'products' }, - { action: 'write', resource: 'products.reference' }, - { action: 'write', resource: 'products.width' }, - { action: 'write', resource: 'products.height' }, - // 'products.description' is missing - { action: 'write', resource: 'products.thumbnail' }, - // 'products.image' is missing - { action: 'write', resource: 'products.step.description' }, - { action: 'write', resource: 'products.step.images' }, - // 'products.step.stock' is missing - ]), -}; +import { + ArrayInput, + Edit, + DateInput, + SimpleFormIterator, + TextInput +} from 'react-admin'; +import { WizardForm } from '@react-admin/ra-form-layout'; -const ProductCreate = () => ( - - - - - - - {/* Won't be displayed */} - - - - {/* Won't be displayed */} - - +const CustomerEdit = () => ( + + + + + - {/* Won't be displayed */} - - + + + + + + + + - + ); ``` -{% endraw %} - -Check [the RBAC ``](./AuthRBAC.md#wizardform) documentation for more details. diff --git a/examples/data-generator/package.json b/examples/data-generator/package.json index 1bfe1c553cb..0d99bd9e3cf 100644 --- a/examples/data-generator/package.json +++ b/examples/data-generator/package.json @@ -1,6 +1,6 @@ { "name": "data-generator-retail", - "version": "5.4.2", + "version": "5.4.3", "homepage": "https://github.com/marmelab/react-admin/tree/master/examples/data-generator", "bugs": "https://github.com/marmelab/react-admin/issues", "license": "MIT", @@ -19,7 +19,7 @@ }, "devDependencies": { "cross-env": "^5.2.0", - "ra-core": "^5.4.2", + "ra-core": "^5.4.3", "rimraf": "^3.0.2", "typescript": "^5.1.3" }, diff --git a/examples/simple/package.json b/examples/simple/package.json index 5b92e0c3f56..6efd10223ce 100644 --- a/examples/simple/package.json +++ b/examples/simple/package.json @@ -1,6 +1,6 @@ { "name": "simple", - "version": "5.4.2", + "version": "5.4.3", "private": true, "type": "module", "scripts": { @@ -18,13 +18,13 @@ "@tanstack/react-query-devtools": "^5.21.7", "jsonexport": "^3.2.0", "lodash": "~4.17.5", - "ra-data-fakerest": "^5.4.2", - "ra-i18n-polyglot": "^5.4.2", - "ra-input-rich-text": "^5.4.2", - "ra-language-english": "^5.4.2", - "ra-language-french": "^5.4.2", + "ra-data-fakerest": "^5.4.3", + "ra-i18n-polyglot": "^5.4.3", + "ra-input-rich-text": "^5.4.3", + "ra-language-english": "^5.4.3", + "ra-language-french": "^5.4.3", "react": "^18.3.1", - "react-admin": "^5.4.2", + "react-admin": "^5.4.3", "react-dom": "^18.3.1", "react-hook-form": "^7.53.0", "react-router": "^6.22.0", diff --git a/lerna.json b/lerna.json index e733addecb2..6012e4e3f8e 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { "lerna": "2.5.1", "packages": ["examples/data-generator", "examples/simple", "packages/*"], - "version": "5.4.2" + "version": "5.4.3" } diff --git a/packages/create-react-admin/package.json b/packages/create-react-admin/package.json index 2fc30c52029..1787c168ff1 100644 --- a/packages/create-react-admin/package.json +++ b/packages/create-react-admin/package.json @@ -1,7 +1,7 @@ { "name": "create-react-admin", "description": "A CLI to quickly start a new react-admin project", - "version": "5.4.2", + "version": "5.4.3", "license": "MIT", "bin": "lib/cli.js", "type": "module", diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index 8c460b9eb63..02fbf33e007 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -1,6 +1,6 @@ { "name": "ra-core", - "version": "5.4.2", + "version": "5.4.3", "description": "Core components of react-admin, a frontend Framework for building admin applications on top of REST services, using ES6, React", "files": [ "*.md", diff --git a/packages/ra-core/src/dataProvider/useUpdate.spec.tsx b/packages/ra-core/src/dataProvider/useUpdate.spec.tsx index 3d4847f0a26..73ea6d7c786 100644 --- a/packages/ra-core/src/dataProvider/useUpdate.spec.tsx +++ b/packages/ra-core/src/dataProvider/useUpdate.spec.tsx @@ -510,7 +510,374 @@ describe('useUpdate', () => { }); }); }); + describe('pessimistic mutation mode', () => { + it('updates getOne query cache when dataProvider promise resolves', async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData( + ['foo', 'getOne', { id: '1', meta: undefined }], + { id: 1, bar: 'bar' } + ); + const dataProvider = { + update: jest.fn(() => + Promise.resolve({ data: { id: 1, bar: 'baz' } } as any) + ), + } as any; + let localUpdate; + const Dummy = () => { + const [update] = useUpdate(); + localUpdate = update; + return ; + }; + render( + + + + ); + localUpdate('foo', { + id: 1, + data: { bar: 'baz' }, + previousData: { id: 1, bar: 'bar' }, + }); + await waitFor(() => { + expect(dataProvider.update).toHaveBeenCalledWith('foo', { + id: 1, + data: { bar: 'baz' }, + previousData: { id: 1, bar: 'bar' }, + }); + }); + await waitFor(() => { + expect( + queryClient.getQueryData([ + 'foo', + 'getOne', + { id: '1', meta: undefined }, + ]) + ).toEqual({ + id: 1, + bar: 'baz', + }); + }); + }); + + it('updates getOne query cache when dataProvider promise resolves with meta', async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData( + ['foo', 'getOne', { id: '1', meta: { key: 'value' } }], + { id: 1, bar: 'bar' } + ); + const dataProvider = { + update: jest.fn(() => + Promise.resolve({ data: { id: 1, bar: 'baz' } } as any) + ), + } as any; + let localUpdate; + const Dummy = () => { + const [update] = useUpdate(); + localUpdate = update; + return ; + }; + render( + + + + ); + localUpdate('foo', { + id: 1, + data: { bar: 'baz' }, + previousData: { id: 1, bar: 'bar' }, + meta: { key: 'value' }, + }); + await waitFor(() => { + expect(dataProvider.update).toHaveBeenCalledWith('foo', { + id: 1, + data: { bar: 'baz' }, + previousData: { id: 1, bar: 'bar' }, + meta: { key: 'value' }, + }); + }); + await waitFor(() => { + expect( + queryClient.getQueryData([ + 'foo', + 'getOne', + { id: '1', meta: { key: 'value' } }, + ]) + ).toEqual({ + id: 1, + bar: 'baz', + }); + }); + }); + + it('updates getOne query cache when dataProvider promise resolves with meta at hook time', async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData( + ['foo', 'getOne', { id: '1', meta: { key: 'value' } }], + { id: 1, bar: 'bar' } + ); + const dataProvider = { + update: jest.fn(() => + Promise.resolve({ data: { id: 1, bar: 'baz' } } as any) + ), + } as any; + let localUpdate; + const Dummy = () => { + const [update] = useUpdate('foo', { + id: 1, + data: { bar: 'baz' }, + previousData: { id: 1, bar: 'bar' }, + meta: { key: 'value' }, + }); + localUpdate = update; + return ; + }; + render( + + + + ); + localUpdate(); + await waitFor(() => { + expect(dataProvider.update).toHaveBeenCalledWith('foo', { + id: 1, + data: { bar: 'baz' }, + previousData: { id: 1, bar: 'bar' }, + meta: { key: 'value' }, + }); + }); + await waitFor(() => { + expect( + queryClient.getQueryData([ + 'foo', + 'getOne', + { id: '1', meta: { key: 'value' } }, + ]) + ).toEqual({ + id: 1, + bar: 'baz', + }); + }); + }); + }); + + describe('optimistic mutation mode', () => { + it('updates getOne query cache immediately and invalidates query when dataProvider promise resolves', async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData( + ['foo', 'getOne', { id: '1', meta: undefined }], + { id: 1, bar: 'bar' } + ); + const dataProvider = { + update: jest.fn(() => + Promise.resolve({ data: { id: 1, bar: 'baz' } } as any) + ), + } as any; + const queryClientSpy = jest.spyOn( + queryClient, + 'invalidateQueries' + ); + let localUpdate; + const Dummy = () => { + const [update] = useUpdate(undefined, undefined, { + mutationMode: 'optimistic', + }); + localUpdate = update; + return ; + }; + render( + + + + ); + localUpdate('foo', { + id: 1, + data: { bar: 'baz' }, + previousData: { id: 1, bar: 'bar' }, + }); + await waitFor(() => { + expect( + queryClient.getQueryData([ + 'foo', + 'getOne', + { id: '1', meta: undefined }, + ]) + ).toEqual({ + id: 1, + bar: 'baz', + }); + }); + await waitFor(() => { + expect(dataProvider.update).toHaveBeenCalledWith('foo', { + id: 1, + data: { bar: 'baz' }, + previousData: { id: 1, bar: 'bar' }, + }); + }); + await waitFor(() => { + expect(queryClientSpy).toHaveBeenCalledWith({ + queryKey: [ + 'foo', + 'getOne', + { id: '1', meta: undefined }, + ], + }); + }); + }); + + it('updates getOne query cache immediately and invalidates query when dataProvider promise resolves with meta', async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData( + ['foo', 'getOne', { id: '1', meta: { key: 'value' } }], + { id: 1, bar: 'bar' } + ); + const dataProvider = { + update: jest.fn(() => + Promise.resolve({ data: { id: 1, bar: 'baz' } } as any) + ), + } as any; + const queryClientSpy = jest.spyOn( + queryClient, + 'invalidateQueries' + ); + let localUpdate; + const Dummy = () => { + const [update] = useUpdate(undefined, undefined, { + mutationMode: 'optimistic', + }); + localUpdate = update; + return ; + }; + render( + + + + ); + localUpdate('foo', { + id: 1, + data: { bar: 'baz' }, + previousData: { id: 1, bar: 'bar' }, + meta: { key: 'value' }, + }); + await waitFor(() => { + expect( + queryClient.getQueryData([ + 'foo', + 'getOne', + { id: '1', meta: { key: 'value' } }, + ]) + ).toEqual({ + id: 1, + bar: 'baz', + }); + }); + await waitFor(() => { + expect(dataProvider.update).toHaveBeenCalledWith('foo', { + id: 1, + data: { bar: 'baz' }, + previousData: { id: 1, bar: 'bar' }, + meta: { key: 'value' }, + }); + }); + await waitFor(() => { + expect(queryClientSpy).toHaveBeenCalledWith({ + queryKey: [ + 'foo', + 'getOne', + { id: '1', meta: { key: 'value' } }, + ], + }); + }); + }); + + it('updates getOne query cache immediately and invalidates query when dataProvider promise resolves with meta at hook time', async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData( + ['foo', 'getOne', { id: '1', meta: { key: 'value' } }], + { id: 1, bar: 'bar' } + ); + const dataProvider = { + update: jest.fn(() => + Promise.resolve({ data: { id: 1, bar: 'baz' } } as any) + ), + } as any; + const queryClientSpy = jest.spyOn( + queryClient, + 'invalidateQueries' + ); + let localUpdate; + const Dummy = () => { + const [update] = useUpdate( + 'foo', + { + id: 1, + data: { bar: 'baz' }, + previousData: { id: 1, bar: 'bar' }, + meta: { key: 'value' }, + }, + { + mutationMode: 'optimistic', + } + ); + localUpdate = update; + return ; + }; + render( + + + + ); + localUpdate(); + await waitFor(() => { + expect( + queryClient.getQueryData([ + 'foo', + 'getOne', + { id: '1', meta: { key: 'value' } }, + ]) + ).toEqual({ + id: 1, + bar: 'baz', + }); + }); + await waitFor(() => { + expect(dataProvider.update).toHaveBeenCalledWith('foo', { + id: 1, + data: { bar: 'baz' }, + previousData: { id: 1, bar: 'bar' }, + meta: { key: 'value' }, + }); + }); + await waitFor(() => { + expect(queryClientSpy).toHaveBeenCalledWith({ + queryKey: [ + 'foo', + 'getOne', + { id: '1', meta: { key: 'value' } }, + ], + }); + }); + }); + }); }); + describe('middlewares', () => { it('when pessimistic, it accepts middlewares and displays result and success side effects when dataProvider promise resolves', async () => { render(); diff --git a/packages/ra-core/src/dataProvider/useUpdate.ts b/packages/ra-core/src/dataProvider/useUpdate.ts index 41cab6c2ec2..9e3e53c21e8 100644 --- a/packages/ra-core/src/dataProvider/useUpdate.ts +++ b/packages/ra-core/src/dataProvider/useUpdate.ts @@ -269,12 +269,13 @@ export const useUpdate = ( const { resource: callTimeResource = resource, id: callTimeId = id, + meta: callTimeMeta = meta, } = variables; updateCache({ resource: callTimeResource, id: callTimeId, data, - meta: mutationOptions.meta ?? paramsRef.current.meta, + meta: callTimeMeta, }); if ( diff --git a/packages/ra-core/src/form/FormDataConsumer.tsx b/packages/ra-core/src/form/FormDataConsumer.tsx index 39205f7ec27..7659835765d 100644 --- a/packages/ra-core/src/form/FormDataConsumer.tsx +++ b/packages/ra-core/src/form/FormDataConsumer.tsx @@ -4,6 +4,7 @@ import { useFormContext, FieldValues } from 'react-hook-form'; import get from 'lodash/get'; import { useFormValues } from './useFormValues'; import { useWrappedSource } from '../core'; +import { useEvent } from '../util'; /** * Get the current (edited) value of the record from the form and pass it @@ -67,22 +68,26 @@ export const FormDataConsumerView = < props: Props ) => { const { children, formData, source } = props; - let ret; + const [result, setResult] = React.useState(null); const finalSource = useWrappedSource(source || ''); + const render = useEvent(children); - // Passes an empty string here as we don't have the children sources and we just want to know if we are in an iterator - const matches = ArraySourceRegex.exec(finalSource); + // Getting the result of the children function in a useEffect allows us to keep a stable reference to is + // with useEvent + React.useEffect(() => { + // Passes an empty string here as we don't have the children sources and we just want to know if we are in an iterator + const matches = ArraySourceRegex.exec(finalSource); + // If we have an index, we are in an iterator like component (such as the SimpleFormIterator) + if (matches) { + const scopedFormData = get(formData, matches[0]); + setResult(render({ formData, scopedFormData })); + } else { + setResult(render({ formData })); + } + }, [finalSource, formData, render]); - // If we have an index, we are in an iterator like component (such as the SimpleFormIterator) - if (matches) { - const scopedFormData = get(formData, matches[0]); - ret = children({ formData, scopedFormData }); - } else { - ret = children({ formData }); - } - - return ret === undefined ? null : ret; + return result; }; const ArraySourceRegex = new RegExp(/.+\.\d+$/); diff --git a/packages/ra-data-fakerest/package.json b/packages/ra-data-fakerest/package.json index 75e09919342..4c28e5cd4dd 100644 --- a/packages/ra-data-fakerest/package.json +++ b/packages/ra-data-fakerest/package.json @@ -1,6 +1,6 @@ { "name": "ra-data-fakerest", - "version": "5.4.2", + "version": "5.4.3", "description": "JSON Server data provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -43,7 +43,7 @@ "@types/jest": "^29.5.2", "cross-env": "^5.2.0", "expect": "^27.4.6", - "ra-core": "^5.4.2", + "ra-core": "^5.4.3", "rimraf": "^3.0.2", "typescript": "^5.1.3" }, diff --git a/packages/ra-data-graphql-simple/package.json b/packages/ra-data-graphql-simple/package.json index 17149f0fa9a..1f4e95f9dc7 100644 --- a/packages/ra-data-graphql-simple/package.json +++ b/packages/ra-data-graphql-simple/package.json @@ -1,6 +1,6 @@ { "name": "ra-data-graphql-simple", - "version": "5.4.2", + "version": "5.4.3", "description": "A GraphQL simple data provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -37,7 +37,7 @@ "graphql-ast-types-browser": "~1.0.2", "lodash": "~4.17.5", "pluralize": "~7.0.0", - "ra-data-graphql": "^5.4.2" + "ra-data-graphql": "^5.4.3" }, "peerDependencies": { "graphql": "^15.6.0", diff --git a/packages/ra-data-graphql/package.json b/packages/ra-data-graphql/package.json index a7fb179255c..2c36bbd9790 100644 --- a/packages/ra-data-graphql/package.json +++ b/packages/ra-data-graphql/package.json @@ -1,6 +1,6 @@ { "name": "ra-data-graphql", - "version": "5.4.2", + "version": "5.4.3", "description": "A GraphQL data provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/packages/ra-data-json-server/package.json b/packages/ra-data-json-server/package.json index f1d05ed01e0..f3a5e2ca9d4 100644 --- a/packages/ra-data-json-server/package.json +++ b/packages/ra-data-json-server/package.json @@ -1,6 +1,6 @@ { "name": "ra-data-json-server", - "version": "5.4.2", + "version": "5.4.3", "description": "JSON Server data provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -26,7 +26,7 @@ }, "dependencies": { "query-string": "^7.1.3", - "ra-core": "^5.4.2" + "ra-core": "^5.4.3" }, "devDependencies": { "cross-env": "^5.2.0", diff --git a/packages/ra-data-localforage/package.json b/packages/ra-data-localforage/package.json index 95bd44c3955..fe22125332d 100644 --- a/packages/ra-data-localforage/package.json +++ b/packages/ra-data-localforage/package.json @@ -1,6 +1,6 @@ { "name": "ra-data-local-forage", - "version": "5.4.2", + "version": "5.4.3", "description": "LocalForage data provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -42,7 +42,7 @@ "dependencies": { "localforage": "^1.7.1", "lodash": "~4.17.5", - "ra-data-fakerest": "^5.4.2" + "ra-data-fakerest": "^5.4.3" }, "devDependencies": { "cross-env": "^5.2.0", diff --git a/packages/ra-data-localstorage/package.json b/packages/ra-data-localstorage/package.json index a390cb2f6c3..2d6d3e77446 100644 --- a/packages/ra-data-localstorage/package.json +++ b/packages/ra-data-localstorage/package.json @@ -1,6 +1,6 @@ { "name": "ra-data-local-storage", - "version": "5.4.2", + "version": "5.4.3", "description": "Local storage data provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -38,7 +38,7 @@ }, "dependencies": { "lodash": "~4.17.5", - "ra-data-fakerest": "^5.4.2" + "ra-data-fakerest": "^5.4.3" }, "devDependencies": { "cross-env": "^5.2.0", diff --git a/packages/ra-data-simple-rest/package.json b/packages/ra-data-simple-rest/package.json index 99b9e73c6bc..8b74a678c86 100644 --- a/packages/ra-data-simple-rest/package.json +++ b/packages/ra-data-simple-rest/package.json @@ -1,6 +1,6 @@ { "name": "ra-data-simple-rest", - "version": "5.4.2", + "version": "5.4.3", "description": "Simple REST data provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -29,7 +29,7 @@ }, "devDependencies": { "cross-env": "^5.2.0", - "ra-core": "^5.4.2", + "ra-core": "^5.4.3", "rimraf": "^3.0.2", "typescript": "^5.1.3" }, diff --git a/packages/ra-i18n-i18next/package.json b/packages/ra-i18n-i18next/package.json index 7cdd1d24418..f80eedc3c36 100644 --- a/packages/ra-i18n-i18next/package.json +++ b/packages/ra-i18n-i18next/package.json @@ -1,6 +1,6 @@ { "name": "ra-i18n-i18next", - "version": "5.4.2", + "version": "5.4.3", "description": "i18next i18n provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -26,7 +26,7 @@ }, "dependencies": { "i18next": "^23.5.1", - "ra-core": "^5.4.2", + "ra-core": "^5.4.3", "react-i18next": "^14.1.1" }, "devDependencies": { diff --git a/packages/ra-i18n-polyglot/package.json b/packages/ra-i18n-polyglot/package.json index e6fe6219a08..29154d23d6d 100644 --- a/packages/ra-i18n-polyglot/package.json +++ b/packages/ra-i18n-polyglot/package.json @@ -1,6 +1,6 @@ { "name": "ra-i18n-polyglot", - "version": "5.4.2", + "version": "5.4.3", "description": "Polyglot i18n provider for react-admin", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", @@ -26,7 +26,7 @@ }, "dependencies": { "node-polyglot": "^2.2.2", - "ra-core": "^5.4.2" + "ra-core": "^5.4.3" }, "devDependencies": { "cross-env": "^5.2.0", diff --git a/packages/ra-input-rich-text/package.json b/packages/ra-input-rich-text/package.json index 363b7928032..d6864e87473 100644 --- a/packages/ra-input-rich-text/package.json +++ b/packages/ra-input-rich-text/package.json @@ -1,6 +1,6 @@ { "name": "ra-input-rich-text", - "version": "5.4.2", + "version": "5.4.3", "description": " component for react-admin, useful for editing HTML code in admin GUIs.", "author": "Gildas Garcia", "repository": "marmelab/react-admin", @@ -51,10 +51,10 @@ "@testing-library/react": "^15.0.7", "@tiptap/extension-mention": "^2.0.3", "@tiptap/suggestion": "^2.0.3", - "data-generator-retail": "^5.4.2", - "ra-core": "^5.4.2", - "ra-data-fakerest": "^5.4.2", - "ra-ui-materialui": "^5.4.2", + "data-generator-retail": "^5.4.3", + "ra-core": "^5.4.3", + "ra-data-fakerest": "^5.4.3", + "ra-ui-materialui": "^5.4.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.0", diff --git a/packages/ra-language-english/package.json b/packages/ra-language-english/package.json index e45174f805b..64d90595556 100644 --- a/packages/ra-language-english/package.json +++ b/packages/ra-language-english/package.json @@ -1,6 +1,6 @@ { "name": "ra-language-english", - "version": "5.4.2", + "version": "5.4.3", "description": "English messages for react-admin, the frontend framework for building admin applications on top of REST/GraphQL services", "repository": { "type": "git", @@ -21,7 +21,7 @@ "watch": "tsc --outDir dist/esm --module es2015 --watch" }, "dependencies": { - "ra-core": "^5.4.2" + "ra-core": "^5.4.3" }, "devDependencies": { "rimraf": "^3.0.2", diff --git a/packages/ra-language-french/package.json b/packages/ra-language-french/package.json index e5c4eef144c..13871099267 100644 --- a/packages/ra-language-french/package.json +++ b/packages/ra-language-french/package.json @@ -1,6 +1,6 @@ { "name": "ra-language-french", - "version": "5.4.2", + "version": "5.4.3", "description": "French messages for react-admin, the frontend framework for building admin applications on top of REST/GraphQL services", "repository": { "type": "git", @@ -21,7 +21,7 @@ "watch": "tsc --outDir dist/esm --module es2015 --watch" }, "dependencies": { - "ra-core": "^5.4.2" + "ra-core": "^5.4.3" }, "devDependencies": { "rimraf": "^3.0.2", diff --git a/packages/ra-no-code/package.json b/packages/ra-no-code/package.json index c8c0d24fc4a..8f12d16ccc5 100644 --- a/packages/ra-no-code/package.json +++ b/packages/ra-no-code/package.json @@ -1,6 +1,6 @@ { "name": "ra-no-code", - "version": "5.4.2", + "version": "5.4.3", "description": "", "files": [ "*.md", @@ -48,8 +48,8 @@ "inflection": "^3.0.0", "lodash": "~4.17.5", "papaparse": "^5.3.0", - "ra-data-local-storage": "^5.4.2", - "react-admin": "^5.4.2", + "ra-data-local-storage": "^5.4.3", + "react-admin": "^5.4.3", "react-dropzone": "^14.2.3" }, "gitHead": "587df4c27bfcec4a756df4f95e5fc14728dfc0d7" diff --git a/packages/ra-ui-materialui/package.json b/packages/ra-ui-materialui/package.json index c19c3e02f33..51cc6bfca48 100644 --- a/packages/ra-ui-materialui/package.json +++ b/packages/ra-ui-materialui/package.json @@ -1,6 +1,6 @@ { "name": "ra-ui-materialui", - "version": "5.4.2", + "version": "5.4.3", "description": "UI Components for react-admin with Material UI", "files": [ "*.md", @@ -39,9 +39,9 @@ "expect": "^27.4.6", "file-api": "~0.10.4", "ignore-styles": "~5.0.1", - "ra-core": "^5.4.2", - "ra-i18n-polyglot": "^5.4.2", - "ra-language-english": "^5.4.2", + "ra-core": "^5.4.3", + "ra-i18n-polyglot": "^5.4.3", + "ra-language-english": "^5.4.3", "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.0", diff --git a/packages/ra-ui-materialui/src/form/FormDataConsumer.stories.tsx b/packages/ra-ui-materialui/src/form/FormDataConsumer.stories.tsx new file mode 100644 index 00000000000..e05cd50afe5 --- /dev/null +++ b/packages/ra-ui-materialui/src/form/FormDataConsumer.stories.tsx @@ -0,0 +1,95 @@ +import * as React from 'react'; +import { FormDataConsumer, required, ResourceContextProvider } from 'ra-core'; +import fakeRestDataProvider from 'ra-data-fakerest'; +import { AdminContext } from '../AdminContext'; +import { AutocompleteInput, ReferenceInput, TextInput } from '../input'; +import { SimpleForm } from './SimpleForm'; +import { Create } from '../detail'; + +// We keep this test in ra-ui-materialui because we need heavy components to reproduce the issue https://github.com/marmelab/react-admin/issues/10415 +export default { title: 'ra-core/form/FormDataConsumer' }; + +export const Basic = () => ( + + + + + + > + {({ formData }) => { + console.log({ formData }); + if (!formData.title) { + return null; + } + return ( + + + `${choice.name} / (${choice.id})` + } + noOptionsText="User doesn't exist" + isRequired + validate={[ + required('User is required.'), + ]} + /> + + ); + }} + + + + + + +); + +const dataProvider = fakeRestDataProvider({ + users: [ + { + id: 1, + name: 'Leanne Graham', + }, + { + id: 2, + name: 'Ervin Howell', + }, + { + id: 3, + name: 'Clementine Bauch', + }, + { + id: 4, + name: 'Patricia Lebsack', + }, + { + id: 5, + name: 'Chelsey Dietrich', + }, + { + id: 6, + name: 'Mrs. Dennis Schulist', + }, + { + id: 7, + name: 'Kurtis Weissnat', + }, + { + id: 8, + name: 'Nicholas Runolfsdottir V', + }, + { + id: 9, + name: 'Glenna Reichert', + }, + { + id: 10, + name: 'Clementina DuBuque', + }, + ], +}); diff --git a/packages/react-admin/package.json b/packages/react-admin/package.json index 3f80ec605bc..d346d72d95a 100644 --- a/packages/react-admin/package.json +++ b/packages/react-admin/package.json @@ -1,6 +1,6 @@ { "name": "react-admin", - "version": "5.4.2", + "version": "5.4.3", "description": "A frontend Framework for building admin applications on top of REST services, using ES6, React and Material UI", "files": [ "*.md", @@ -40,10 +40,10 @@ "@emotion/styled": "^11.3.0", "@mui/icons-material": "^5.15.20", "@mui/material": "^5.15.20", - "ra-core": "^5.4.2", - "ra-i18n-polyglot": "^5.4.2", - "ra-language-english": "^5.4.2", - "ra-ui-materialui": "^5.4.2", + "ra-core": "^5.4.3", + "ra-i18n-polyglot": "^5.4.3", + "ra-language-english": "^5.4.3", + "ra-ui-materialui": "^5.4.3", "react-hook-form": "^7.53.0", "react-router": "^6.22.0", "react-router-dom": "^6.22.0" diff --git a/yarn.lock b/yarn.lock index 7687f3f8815..dbff39fa5a0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8054,14 +8054,14 @@ __metadata: languageName: node linkType: hard -"data-generator-retail@npm:^5.0.0, data-generator-retail@npm:^5.4.2, data-generator-retail@workspace:examples/data-generator": +"data-generator-retail@npm:^5.0.0, data-generator-retail@npm:^5.4.3, data-generator-retail@workspace:examples/data-generator": version: 0.0.0-use.local resolution: "data-generator-retail@workspace:examples/data-generator" dependencies: cross-env: "npm:^5.2.0" date-fns: "npm:^3.6.0" faker: "npm:^4.1.0" - ra-core: "npm:^5.4.2" + ra-core: "npm:^5.4.3" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" peerDependencies: @@ -13988,11 +13988,11 @@ __metadata: linkType: hard "nanoid@npm:^3.3.7": - version: 3.3.7 - resolution: "nanoid@npm:3.3.7" + version: 3.3.8 + resolution: "nanoid@npm:3.3.8" bin: nanoid: bin/nanoid.cjs - checksum: e3fb661aa083454f40500473bb69eedb85dc160e763150b9a2c567c7e9ff560ce028a9f833123b618a6ea742e311138b591910e795614a629029e86e180660f3 + checksum: 4b1bb29f6cfebf3be3bc4ad1f1296fb0a10a3043a79f34fbffe75d1621b4318319211cd420549459018ea3592f0d2f159247a6f874911d6d26eaaadda2478120 languageName: node linkType: hard @@ -15826,7 +15826,7 @@ __metadata: languageName: node linkType: hard -"ra-core@npm:^5.4.2, ra-core@workspace:packages/ra-core": +"ra-core@npm:^5.4.3, ra-core@workspace:packages/ra-core": version: 0.0.0-use.local resolution: "ra-core@workspace:packages/ra-core" dependencies: @@ -15871,7 +15871,7 @@ __metadata: languageName: unknown linkType: soft -"ra-data-fakerest@npm:^5.3.0, ra-data-fakerest@npm:^5.4.2, ra-data-fakerest@workspace:packages/ra-data-fakerest": +"ra-data-fakerest@npm:^5.3.0, ra-data-fakerest@npm:^5.4.3, ra-data-fakerest@workspace:packages/ra-data-fakerest": version: 0.0.0-use.local resolution: "ra-data-fakerest@workspace:packages/ra-data-fakerest" dependencies: @@ -15879,7 +15879,7 @@ __metadata: cross-env: "npm:^5.2.0" expect: "npm:^27.4.6" fakerest: "npm:^4.0.1" - ra-core: "npm:^5.4.2" + ra-core: "npm:^5.4.3" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" peerDependencies: @@ -15897,7 +15897,7 @@ __metadata: graphql-ast-types-browser: "npm:~1.0.2" lodash: "npm:~4.17.5" pluralize: "npm:~7.0.0" - ra-data-graphql: "npm:^5.4.2" + ra-data-graphql: "npm:^5.4.3" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" peerDependencies: @@ -15906,7 +15906,7 @@ __metadata: languageName: unknown linkType: soft -"ra-data-graphql@npm:^5.0.0, ra-data-graphql@npm:^5.4.2, ra-data-graphql@workspace:packages/ra-data-graphql": +"ra-data-graphql@npm:^5.0.0, ra-data-graphql@npm:^5.4.3, ra-data-graphql@workspace:packages/ra-data-graphql": version: 0.0.0-use.local resolution: "ra-data-graphql@workspace:packages/ra-data-graphql" dependencies: @@ -15929,7 +15929,7 @@ __metadata: dependencies: cross-env: "npm:^5.2.0" query-string: "npm:^7.1.3" - ra-core: "npm:^5.4.2" + ra-core: "npm:^5.4.3" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" languageName: unknown @@ -15942,7 +15942,7 @@ __metadata: cross-env: "npm:^5.2.0" localforage: "npm:^1.7.1" lodash: "npm:~4.17.5" - ra-data-fakerest: "npm:^5.4.2" + ra-data-fakerest: "npm:^5.4.3" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" peerDependencies: @@ -15950,13 +15950,13 @@ __metadata: languageName: unknown linkType: soft -"ra-data-local-storage@npm:^5.0.0, ra-data-local-storage@npm:^5.4.2, ra-data-local-storage@workspace:packages/ra-data-localstorage": +"ra-data-local-storage@npm:^5.0.0, ra-data-local-storage@npm:^5.4.3, ra-data-local-storage@workspace:packages/ra-data-localstorage": version: 0.0.0-use.local resolution: "ra-data-local-storage@workspace:packages/ra-data-localstorage" dependencies: cross-env: "npm:^5.2.0" lodash: "npm:~4.17.5" - ra-data-fakerest: "npm:^5.4.2" + ra-data-fakerest: "npm:^5.4.3" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" peerDependencies: @@ -15970,7 +15970,7 @@ __metadata: dependencies: cross-env: "npm:^5.2.0" query-string: "npm:^7.1.3" - ra-core: "npm:^5.4.2" + ra-core: "npm:^5.4.3" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" peerDependencies: @@ -15985,26 +15985,26 @@ __metadata: cross-env: "npm:^5.2.0" i18next: "npm:^23.5.1" i18next-resources-to-backend: "npm:^1.1.4" - ra-core: "npm:^5.4.2" + ra-core: "npm:^5.4.3" react-i18next: "npm:^14.1.1" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" languageName: unknown linkType: soft -"ra-i18n-polyglot@npm:^5.0.0, ra-i18n-polyglot@npm:^5.4.2, ra-i18n-polyglot@workspace:packages/ra-i18n-polyglot": +"ra-i18n-polyglot@npm:^5.0.0, ra-i18n-polyglot@npm:^5.4.3, ra-i18n-polyglot@workspace:packages/ra-i18n-polyglot": version: 0.0.0-use.local resolution: "ra-i18n-polyglot@workspace:packages/ra-i18n-polyglot" dependencies: cross-env: "npm:^5.2.0" node-polyglot: "npm:^2.2.2" - ra-core: "npm:^5.4.2" + ra-core: "npm:^5.4.3" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" languageName: unknown linkType: soft -"ra-input-rich-text@npm:^5.0.0, ra-input-rich-text@npm:^5.4.2, ra-input-rich-text@workspace:packages/ra-input-rich-text": +"ra-input-rich-text@npm:^5.0.0, ra-input-rich-text@npm:^5.4.3, ra-input-rich-text@workspace:packages/ra-input-rich-text": version: 0.0.0-use.local resolution: "ra-input-rich-text@workspace:packages/ra-input-rich-text" dependencies: @@ -16026,10 +16026,10 @@ __metadata: "@tiptap/starter-kit": "npm:^2.0.3" "@tiptap/suggestion": "npm:^2.0.3" clsx: "npm:^2.1.1" - data-generator-retail: "npm:^5.4.2" - ra-core: "npm:^5.4.2" - ra-data-fakerest: "npm:^5.4.2" - ra-ui-materialui: "npm:^5.4.2" + data-generator-retail: "npm:^5.4.3" + ra-core: "npm:^5.4.3" + ra-data-fakerest: "npm:^5.4.3" + ra-ui-materialui: "npm:^5.4.3" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" react-hook-form: "npm:^7.53.0" @@ -16046,21 +16046,21 @@ __metadata: languageName: unknown linkType: soft -"ra-language-english@npm:^5.0.0, ra-language-english@npm:^5.4.2, ra-language-english@workspace:packages/ra-language-english": +"ra-language-english@npm:^5.0.0, ra-language-english@npm:^5.4.3, ra-language-english@workspace:packages/ra-language-english": version: 0.0.0-use.local resolution: "ra-language-english@workspace:packages/ra-language-english" dependencies: - ra-core: "npm:^5.4.2" + ra-core: "npm:^5.4.3" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" languageName: unknown linkType: soft -"ra-language-french@npm:^5.0.0, ra-language-french@npm:^5.4.2, ra-language-french@workspace:packages/ra-language-french": +"ra-language-french@npm:^5.0.0, ra-language-french@npm:^5.4.3, ra-language-french@workspace:packages/ra-language-french": version: 0.0.0-use.local resolution: "ra-language-french@workspace:packages/ra-language-french" dependencies: - ra-core: "npm:^5.4.2" + ra-core: "npm:^5.4.3" rimraf: "npm:^3.0.2" typescript: "npm:^5.1.3" languageName: unknown @@ -16079,9 +16079,9 @@ __metadata: inflection: "npm:^3.0.0" lodash: "npm:~4.17.5" papaparse: "npm:^5.3.0" - ra-data-local-storage: "npm:^5.4.2" + ra-data-local-storage: "npm:^5.4.3" react: "npm:^18.3.1" - react-admin: "npm:^5.4.2" + react-admin: "npm:^5.4.3" react-dom: "npm:^18.3.1" react-dropzone: "npm:^14.2.3" react-router: "npm:^6.22.0" @@ -16096,7 +16096,7 @@ __metadata: languageName: unknown linkType: soft -"ra-ui-materialui@npm:^5.4.2, ra-ui-materialui@workspace:packages/ra-ui-materialui": +"ra-ui-materialui@npm:^5.4.3, ra-ui-materialui@workspace:packages/ra-ui-materialui": version: 0.0.0-use.local resolution: "ra-ui-materialui@workspace:packages/ra-ui-materialui" dependencies: @@ -16122,9 +16122,9 @@ __metadata: jsonexport: "npm:^3.2.0" lodash: "npm:~4.17.5" query-string: "npm:^7.1.3" - ra-core: "npm:^5.4.2" - ra-i18n-polyglot: "npm:^5.4.2" - ra-language-english: "npm:^5.4.2" + ra-core: "npm:^5.4.3" + ra-i18n-polyglot: "npm:^5.4.3" + ra-language-english: "npm:^5.4.3" react: "npm:^18.3.1" react-dom: "npm:^18.3.1" react-dropzone: "npm:^14.2.3" @@ -16284,7 +16284,7 @@ __metadata: languageName: unknown linkType: soft -"react-admin@npm:^5.0.0, react-admin@npm:^5.3.0, react-admin@npm:^5.4.2, react-admin@workspace:packages/react-admin": +"react-admin@npm:^5.0.0, react-admin@npm:^5.3.0, react-admin@npm:^5.4.3, react-admin@workspace:packages/react-admin": version: 0.0.0-use.local resolution: "react-admin@workspace:packages/react-admin" dependencies: @@ -16294,10 +16294,10 @@ __metadata: "@mui/material": "npm:^5.15.20" cross-env: "npm:^5.2.0" expect: "npm:^27.4.6" - ra-core: "npm:^5.4.2" - ra-i18n-polyglot: "npm:^5.4.2" - ra-language-english: "npm:^5.4.2" - ra-ui-materialui: "npm:^5.4.2" + ra-core: "npm:^5.4.3" + ra-i18n-polyglot: "npm:^5.4.3" + ra-language-english: "npm:^5.4.3" + ra-ui-materialui: "npm:^5.4.3" react-hook-form: "npm:^7.53.0" react-router: "npm:^6.22.0" react-router-dom: "npm:^6.22.0" @@ -17602,13 +17602,13 @@ __metadata: "@vitejs/plugin-react": "npm:^4.2.1" jsonexport: "npm:^3.2.0" lodash: "npm:~4.17.5" - ra-data-fakerest: "npm:^5.4.2" - ra-i18n-polyglot: "npm:^5.4.2" - ra-input-rich-text: "npm:^5.4.2" - ra-language-english: "npm:^5.4.2" - ra-language-french: "npm:^5.4.2" + ra-data-fakerest: "npm:^5.4.3" + ra-i18n-polyglot: "npm:^5.4.3" + ra-input-rich-text: "npm:^5.4.3" + ra-language-english: "npm:^5.4.3" + ra-language-french: "npm:^5.4.3" react: "npm:^18.3.1" - react-admin: "npm:^5.4.2" + react-admin: "npm:^5.4.3" react-dom: "npm:^18.3.1" react-hook-form: "npm:^7.53.0" react-router: "npm:^6.22.0"