diff --git a/UPGRADE.md b/UPGRADE.md index f452c412fc6..9a5661534a9 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -273,3 +273,39 @@ export const UserCreate = (props) => ( ); ``` + +## `authProvider` No Longer Uses Legacy React Context + +When you provide an `authProvider` to the `` component, react-admin creates a context to make it available everywhere in the application. In version 2.x, this used the [legacy React context API](https://reactjs.org/docs/legacy-context.html). In 3.0, this uses the normal context API. That means that any context consumer will need to use the new context API. + +```diff +-import React from 'react'; ++import React, { useContext } from 'react'; ++import { AuthContext } from 'react-admin'; + +-const MyComponentWithAuthProvider = (props, context) => { ++const MyComponentWithAuthProvider = (props) => { ++ const authProvider = useContext(AuthContext); + authProvider('AUTH_CHECK'); + return
I'm authenticated
; +} + +-MyComponentWithAuthProvider.contextTypes = { authProvider: PropTypes.object } +``` + +If you didn't access the `authProvider` context manually, you have nothing to change. All react-admin components have been updated to use the new context API. + +Note that direct access to the `authProvider` from the context is discouraged (and not documented). If you need to interact with the `authProvider`, use the new `useAuth()` and `usePermissions()` hooks, or the auth-related action creators (`userLogin`, `userLogout`, `userCheck`). + +## `authProvider` No Longer Receives `match` in Params + +Whenever it called the `authProvider`, react-admin used to pass both the `location` and the `match` object from react-router. In v3, the `match` object is no longer passed as argument. There is no legitimate usage of this parameter we can think about, and it forced passing down that object across several components for nothing, so it's been removed. Upgrade your `authProvider` to remove that param. + +```diff +// in src/authProvider +export default (type, params) => { +- const { location, match } = params; ++ const { location } = params; + // ... +} +``` diff --git a/docs/Authentication.md b/docs/Authentication.md index af6bc4ec886..5523486babc 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -9,15 +9,40 @@ title: "Authentication" React-admin lets you secure your admin app with the authentication strategy of your choice. Since there are many different possible strategies (Basic Auth, JWT, OAuth, etc.), react-admin simply provides hooks to execute your own authentication code. -By default, react-admin apps don't require authentication. Here are the steps to add one. +## The `authProvider` -## Configuring the Auth Provider +By default, react-admin apps don't require authentication. To restrict access to the admin, pass an `authProvider` to the `` component. -By default, the `/login` route renders a special component called `Login`, which displays a login form asking for username and password. +```jsx +// in src/App.js +import authProvider from './authProvider'; + +const App = () => ( + + ... + +); +``` + +What's an `authProvider`? Just like a `dataProvider`, an `authProvider` is a function that react-admin calls when needed, and that returns a Promise. The signature of an `authProvider` is: + +```js +// in src/authProvider.js + +const authProvider = (type, params) => Promise.resolve; + +export default authProvider; +``` + +Let's see when react-admin calls the `authProvider`, and with which params. + +## Login Configuration + +Once an admin has an `authProvider`, react-admin enables a new page on the `/login` route, which displays a login form asking for username and password. ![Default Login Form](./img/login-form.png) -What this form does upon submission depends on the `authProvider` prop of the `` component. This function receives authentication requests `(type, params)`, and should return a Promise. `Login` calls `authProvider` with the `AUTH_LOGIN` type, and `{ login, password }` as parameters. It's the ideal place to authenticate the user, and store their credentials. +Upon submission, this form calls the `authProvider` with the `AUTH_LOGIN` type, and `{ login, password }` as parameters. It's the ideal place to authenticate the user, and store their credentials. For instance, to query an authentication route via HTTPS and store the credentials (a token) in local storage, configure `authProvider` as follows: @@ -25,7 +50,7 @@ For instance, to query an authentication route via HTTPS and store the credentia // in src/authProvider.js import { AUTH_LOGIN } from 'react-admin'; -export default (type, params) => { +const authProvider = (type, params) => { if (type === AUTH_LOGIN) { const { username, password } = params; const request = new Request('https://mydomain.com/authenticate', { @@ -46,28 +71,17 @@ export default (type, params) => { } return Promise.resolve(); } -``` - -**Tip**: It's a good idea to store credentials in `localStorage`, to avoid reconnection when opening a new browser tab. But this makes your application [open to XSS attacks](http://www.redotheweb.com/2015/11/09/api-security.html), so you'd better double down on security, and add an `httpOnly` cookie on the server side, too. - -Then, pass this client to the `` component: - -```jsx -// in src/App.js -import authProvider from './authProvider'; -const App = () => ( - - ... - -); +export default authProvider; ``` -Upon receiving a 403 response, the admin app shows the Login page. `authProvider` is now called when the user submits the login form. Once the promise resolves, the login form redirects to the previous page, or to the admin index if the user just arrived. +Once the promise resolves, the login form redirects to the previous page, or to the admin index if the user just arrived. + +**Tip**: It's a good idea to store credentials in `localStorage`, as in this example, to avoid reconnection when opening a new browser tab. But this makes your application [open to XSS attacks](http://www.redotheweb.com/2015/11/09/api-security.html), so you'd better double down on security, and add an `httpOnly` cookie on the server side, too. ## Sending Credentials to the API -To use the credentials when calling a data provider, you have to tweak, this time, the `dataProvider` function. As explained in the [Data providers documentation](DataProviders.md#adding-custom-headers), `simpleRestProvider` and `jsonServerProvider` take an `httpClient` as second parameter. That's the place where you can change request headers, cookies, etc. +Now that the user has logged in, you can use their credentials to communicate with the `dataProvider`. For that, you have to tweak, this time, the `dataProvider` function. As explained in the [Data providers documentation](DataProviders.md#adding-custom-headers), `simpleRestProvider` and `jsonServerProvider` take an `httpClient` as second parameter. That's the place where you can change request headers, cookies, etc. For instance, to pass the token obtained during login as an `Authorization` header, configure the Data Provider as follows: @@ -92,13 +106,15 @@ const App = () => ( ); ``` +Now the admin is secured: The user can be authenticatded and use their credentials to communicate with a secure API. + If you have a custom REST client, don't forget to add credentials yourself. -## Adding a Logout Button +## Logout Configuration -If you provide an `authProvider` prop to ``, react-admin displays a logout button in the top bar (or in the menu on mobile). When the user clicks on the logout button, this calls the `authProvider` with the `AUTH_LOGOUT` type and removes potentially sensitive data from the redux store. When resolved, the user gets redirected to the login page. +If you provide an `authProvider` prop to ``, react-admin displays a logout button in the top bar (or in the menu on mobile). When the user clicks on the logout button, this calls the `authProvider` with the `AUTH_LOGOUT` type and removes potentially sensitive data from the Redux store. Then the user gets redirected to the login page. -For instance, to remove the token from local storage upon logout: +So it's the responsibility of the `authProvider` to cleanup the current authentication data. For instance, if the authentication was a token stored in local storage, here the code to remove it: ```jsx // in src/authProvider.js @@ -122,9 +138,9 @@ The `authProvider` is also a good place to notify the authentication API that th ## Catching Authentication Errors On The API -If the API requires authentication, and the user credentials are missing or invalid in the request, the API usually answers with an error code 401 or 403. +If the API requires authentication, and the user credentials are missing in the request or invalid, the API usually answers with an HTTP error code 401 or 403. -Fortunately, each time the API returns an error, the `authProvider` is called with the `AUTH_ERROR` type. Once again, it's up to you to decide which HTTP status codes should let the user continue (by returning a resolved promise) or log them out (by returning a rejected promise). +Fortunately, each time the API returns an error, react-admin calls the `authProvider` with the `AUTH_ERROR` type. Once again, it's up to you to decide which HTTP status codes should let the user continue (by returning a resolved promise) or log them out (by returning a rejected promise). For instance, to redirect the user to the login page for both 401 and 403 codes: @@ -155,7 +171,7 @@ export default (type, params) => { Redirecting to the login page whenever a REST response uses a 401 status code is usually not enough, because react-admin keeps data on the client side, and could display stale data while contacting the server - even after the credentials are no longer valid. -Fortunately, each time the user navigates, react-admin calls the `authProvider` with the `AUTH_CHECK` type, so it's the ideal place to check for credentials. +Fortunately, each time the user navigates, react-admin calls the `authProvider` with the `AUTH_CHECK` type, so it's the ideal place to validate the credentials. For instance, to check for the existence of the token in local storage: @@ -176,7 +192,7 @@ export default (type, params) => { if (type === AUTH_CHECK) { return localStorage.getItem('token') ? Promise.resolve() : Promise.reject(); } - return Promise.reject('Unknown method'); + return Promise.resolve(); }; ``` @@ -197,9 +213,11 @@ export default (type, params) => { // ... } if (type === AUTH_CHECK) { - return localStorage.getItem('token') ? Promise.resolve() : Promise.reject({ redirectTo: '/no-access' }); + return localStorage.getItem('token') + ? Promise.resolve() + : Promise.reject({ redirectTo: '/no-access' }); } - return Promise.reject('Unknown method'); + return Promise.resolve(); }; ``` @@ -228,11 +246,11 @@ export default (type, params) => { // check credentials for the comments resource } } - return Promise.reject('Unknown method'); + return Promise.resolve(); }; ``` -**Tip**: The `authProvider` can only be called with `AUTH_LOGIN`, `AUTH_LOGOUT`, `AUTH_ERROR`, or `AUTH_CHECK`; that's why the final return is a rejected promise. +**Tip**: In addition to `AUTH_LOGIN`, `AUTH_LOGOUT`, `AUTH_ERROR`, and `AUTH_CHECK`, react-admin calls the `authProvider` with the `AUTH_GET_PERMISSIONS` type to check user permissions. It's useful to enable or disable features on a per user basis. Read the [Authorization Documentation](./Authorization.md) to learn how to implement that type. ## Customizing The Login and Logout Components @@ -246,64 +264,68 @@ For all these cases, it's up to you to implement your own `LoginPage` component, ```jsx // in src/MyLoginPage.js -import React, { Component } from 'react'; -import { connect } from 'react-redux'; +import React, { useState } from 'react'; +import { useDispatch } from 'react-redux'; import { userLogin } from 'react-admin'; import { ThemeProvider } from '@material-ui/styles'; -class MyLoginPage extends Component { - submit = (e) => { +const MyLoginPage = ({ theme }) => { + const dispatch = useDispatch() + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const submit = (e) => { e.preventDefault(); // gather your data/credentials here - const credentials = { }; - - // Dispatch the userLogin action (injected by connect) - this.props.userLogin(credentials); + const credentials = { email, password }; + // Dispatch the userLogin action + dispatch(userLogin(credentials)); } - render() { - return ( - -
- ... -
-
- ); - } + return ( + +
+ setEmail(e.target.value)} /> + setPassword(e.target.value)} /> +
+
+ ); }; -export default connect(undefined, { userLogin })(MyLoginPage); +export default MyLoginPage; // in src/MyLogoutButton.js import React from 'react'; -import { connect } from 'react-redux'; +import { useDispatch } from 'react-redux'; import { Responsive, userLogout } from 'react-admin'; import MenuItem from '@material-ui/core/MenuItem'; import Button from '@material-ui/core/Button'; import ExitIcon from '@material-ui/icons/PowerSettingsNew'; -const MyLogoutButton = ({ userLogout, ...rest }) => ( - - Logout - - } - medium={ - - } - /> -); -export default connect(undefined, { userLogout })(MyLogoutButton); +const MyLogoutButton = props => { + const dispatch = useDispatch(); + return ( + dispatch(userLogout())} + {...props} + > + Logout + + } + medium={ + + } + /> + ); +}; +export default MyLogoutButton; // in src/App.js import MyLoginPage from './MyLoginPage'; @@ -316,40 +338,75 @@ const App = () => ( ); ``` -## Restricting Access To A Custom Page +**Tip**: By default, react-admin redirects the user to '/login' after they log out. This can be changed by passing the url to redirect to as parameter to the `userLogout()` action creator: + +```diff +// in src/MyLogoutButton.js +// ... + dispatch(userLogout())} ++ onClick={() => dispatch(userLogout('/custom-login'))} + {...props} + > + Logout + +``` + +## `useAuth()` Hook -If you add [custom pages](./Actions.md), of if you [create an admin app from scratch](./CustomApp.md), you may need to secure access to pages manually. That's the purpose of the `` component, that you can use as a decorator for your own components. +If you add [custom pages](./Actions.md), of if you [create an admin app from scratch](./CustomApp.md), you may need to secure access to pages manually. That's the purpose of the `useAuth()` hook, which calls the `authProvider` with the `AUTH_CHECK` type on mount, and redirects to login if it returns a rejected Promise. -{% raw %} ```jsx // in src/MyPage.js -import { withRouter } from 'react-router-dom'; -import { Authenticated } from 'react-admin'; +import { useAuth } from 'react-admin'; -const MyPage = ({ location }) => ( - +const MyPage = () => { + useAuth(); // redirects to login if not authenticated + return (
...
-
-); + ) +}; -export default withRouter(MyPage); +export default MyPage; ``` -{% endraw %} -The `` component calls the `authProvider` function with `AUTH_CHECK` and `authParams`. If the response is a fulfilled promise, the child component is rendered. If the response is a rejected promise, `` redirects to the login form. Upon successful login, the user is redirected to the initial location (that's why it's necessary to get the location from the router). +If you call `useAuth()` with a parameter, this parameter is passed to the `authProvider` call as second parameter. that allows you to add authentication logic depending on the context of the call: +```jsx +const MyPage = () => { + useAuth({ foo: 'bar' }); // calls authProvider(AUTH_CHECK, { foo: 'bar' }) + return ( +
+ ... +
+ ) +}; +``` -## Redirect After Logout +The `useAuth` hook is optimistic: it doesn't block rendering during the `authProvider` call. In the above example, the `MyPage` component renders even before getting the response from the `authProvider`. If the call returns a rejected promise, the hook redirects to the login page, but the user may have seen the content of the `MyPage` component for a brief moment. -By default, react-admin redirects the user to '/login' after they log out. This can be changed by passing the url to redirect to as parameter to the `userLogout()` action creator when you `connect` the `MyLogoutButton` component: +To avoid rendering a component and force waiting for the `authProvider` response, use the return value of the `useAuth()` hook. -```diff -// in src/MyLogoutButton.js -// ... -- export default connect(undefined, { userLogout })(MyLogoutButton); -+ const redirectTo = '/'; -+ const myCustomUserLogout = () => userLogout(redirectTo); -+ export default connect(undefined, { userLogout: myCustomUserLogout })(MyLogoutButton); +```jsx +const MyPage = () => { + const { loaded } = useAuth(); + return loaded ? ( +
+ ... +
+ ) : null; +}; +``` + +Also, you may want to show special content instead of redirecting to login if the user isn't authenticated. Pass an options argument with `logoutOnFailure` set to `false` to disable this feature: + +```jsx +const MyPage = () => { + const { loaded, authenticated } = useAuth({}, { logoutOnFailure: false }); + if (!loaded) return null; + if (!authenticated) return ; + return +}; ``` diff --git a/docs/Authorization.md b/docs/Authorization.md index 4a08e4f00e1..c6db4d4ff81 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -5,15 +5,15 @@ title: "Authorization" # Authorization -Some applications may require to determine what level of access a particular authenticated user should have to secured resources. Since there are many different possible strategies (single role, multiple roles or rights, etc.), react-admin simply provides hooks to execute your own authorization code. +Some applications may require fine grained permissions to enable or disable access to certain features. Since there are many different possible strategies (single role, multiple roles or rights, ACLs, etc.), react-admin simply provides hooks to execute your own authorization code. -By default, a react-admin app doesn't require authorization. However, if needed, it will rely on the `authProvider` introduced in the [Authentication](./Authentication.md) section. +By default, a react-admin app doesn't check authorization. However, if needed, it will rely on the `authProvider` introduced in the [Authentication documentation](./Authentication.md) to do so. You should read that chapter first. ## Configuring the Auth Provider -A call to the `authProvider` with the `AUTH_GET_PERMISSIONS` type will be made each time a component requires to check the user's permissions. +Each time react-admin needs to determine the user permissions, it calls the `authProvider` with the `AUTH_GET_PERMISSIONS` type. It's up to you to return the user permissions, be it a string (e.g. `'admin'`) or and array of roles (e.g. `['post_editor', 'comment_moderator', 'super_admin']`). -Following is an example where the `authProvider` stores the user's role upon authentication, and returns it when called for a permissions check: +Following is an example where the `authProvider` stores the user's permissions in `localStorage` upon authentication, and returns these permissions when called with `AUTH_GET_PERMISSIONS`: {% raw %} ```jsx @@ -39,12 +39,12 @@ export default (type, params) => { .then(({ token }) => { const decodedToken = decodeJwt(token); localStorage.setItem('token', token); - localStorage.setItem('role', decodedToken.role); + localStorage.setItem('permissions', decodedToken.permissions); }); } if (type === AUTH_LOGOUT) { localStorage.removeItem('token'); - localStorage.removeItem('role'); + localStorage.removeItem('permissions'); return Promise.resolve(); } if (type === AUTH_ERROR) { @@ -54,7 +54,7 @@ export default (type, params) => { return localStorage.getItem('token') ? Promise.resolve() : Promise.reject(); } if (type === AUTH_GET_PERMISSIONS) { - const role = localStorage.getItem('role'); + const role = localStorage.getItem('permissions'); return role ? Promise.resolve(role) : Promise.reject(); } return Promise.reject('Unknown method'); @@ -64,9 +64,8 @@ export default (type, params) => { ## Restricting Access to Resources or Views -It's possible to restrict access to resources or their views inside the `Admin` component. To do so, you must specify a function as the `Admin` only child. This function will be called with the permissions returned by the `authProvider`. +Permissions can be useful to to restrict access to resources or their views. To do so, you must use a function as the `` only child. React-admin will call this function with the permissions returned by the `authProvider`. -{% raw %} ```jsx ``` -{% endraw %} Note that the function returns an array of React elements. This is required to avoid having to wrap them in a container element which would prevent the `Admin` from working. -**Tip** Even if that's possible, be careful when completely excluding a resource (like with the `categories` resource in this example) as it will prevent you to reference them in the other resource views, too. +**Tip**: Even if that's possible, be careful when completely excluding a resource (like with the `categories` resource in this example) as it will prevent you to reference this resource in the other resource views, too. ## Restricting Access to Fields and Inputs -You might want to display some fields or inputs only to users with specific permissions. Those permissions are retrieved for each route and will provided to your component as a `permissions` prop. - -Each route will call the `authProvider` with the `AUTH_GET_PERMISSIONS` type and some parameters including the current location and route parameters. It's up to you to return whatever you need to check inside your component such as the user's role, etc. +You might want to display some fields or inputs only to users with specific permissions. By default, react-admin calls the `authProvider` for permissions for each resource routes, and passes them to the `list`, `edit`, `create`, and `show` components. -Here's an example inside a `Create` view with a `SimpleForm` and a custom `Toolbar`: +Here is an example of a `Create` view with a conditionnal Input based on permissions: {% raw %} ```jsx -const UserCreateToolbar = ({ permissions, ...props }) => - - - {permissions === 'admin' && - } - ; - export const UserCreate = ({ permissions, ...props }) => } defaultValue={{ role: 'user' }} > @@ -133,9 +112,7 @@ export const UserCreate = ({ permissions, ...props }) => ``` {% endraw %} -**Tip** Note how the `permissions` prop is passed down to the custom `toolbar` component. - -This also works inside an `Edition` view with a `TabbedForm`, and you can hide a `FormTab` completely: +This also works inside an `Edition` view with a `TabbedForm`, and you can even hide a `FormTab` completely: {% raw %} ```jsx @@ -155,9 +132,8 @@ export const UserEdit = ({ permissions, ...props }) => ``` {% endraw %} -What about the `List` view, the `DataGrid`, `SimpleList` and `Filter` components? It works there, too. +What about the `List` view, the `DataGrid`, `SimpleList` and `Filter` components? It works there, too. And in the next example, the `permissions` prop is passed down to a custom `filters` component. -{% raw %} ```jsx const UserFilter = ({ permissions, ...props }) => @@ -167,44 +143,30 @@ const UserFilter = ({ permissions, ...props }) => alwaysOn /> - {permissions === 'admin' ? : null} + {permissions === 'admin' && } ; export const UserList = ({ permissions, ...props }) => } - sort={{ field: 'name', order: 'ASC' }} > - record.name} - secondaryText={record => - permissions === 'admin' ? record.role : null} - /> - } - medium={ - - - - {permissions === 'admin' && } - {permissions === 'admin' && } - - - } - /> + + + + {permissions === 'admin' && } + {permissions === 'admin' && } + + ; ``` -{% endraw %} -**Tip** Note how the `permissions` prop is passed down to the custom `filters` component. +**Tip**: When calling the `authProvider` with the `AUTH_GET_PERMISSIONS` type, react-admin passes the current location. You can use this information to implement location-based authorization. -## Restricting Access to Content Inside a Dashboard +## Restricting Access to the Dashboard -The component provided as a [`dashboard`]('./Admin.md#dashboard) will receive the permissions in its props too: +React-admin injects the permissions into the component provided as a [`dashboard`]('./Admin.md#dashboard), too: -{% raw %} ```jsx // in src/Dashboard.js import React from 'react'; @@ -223,83 +185,79 @@ export default ({ permissions }) => ( ); ``` -{% endraw %} -## Restricting Access to Content Inside Custom Pages +## `usePermissions()` Hook -You might want to check user permissions inside a [custom pages](./Admin.md#customroutes). You'll have to use the `WithPermissions` component for that. It will ensure the user is authenticated then call the `authProvider` with the `AUTH_GET_PERMISSIONS` type and the `authParams` you specify: +You might want to check user permissions inside a [custom page](./Admin.md#customroutes). That's the purpose of the `usePermissions()` hook,which calls the `authProvider` with the `AUTH_GET_PERMISSIONS` type on mount, and returns the result when available: -{% raw %} ```jsx // in src/MyPage.js import React from 'react'; import Card from '@material-ui/core/Card'; import CardContent from '@material-ui/core/CardContent'; -import { Title, WithPermissions } from 'react-admin'; -import { withRouter } from 'react-router-dom'; +import { usePermissions } from 'react-admin'; -const MyPage = ({ permissions }) => ( - - - <CardContent>Lorem ipsum sic dolor amet...</CardContent> - {permissions === 'admin' - ? <CardContent>Sensitive data</CardContent> - : null - } - </Card> -) -const MyPageWithPermissions = ({ location, match }) => ( - <WithPermissions - authParams={{ key: match.path, params: route.params }} - // location is not required but it will trigger a new permissions check if specified when it changes - location={location} - render={({ permissions }) => <MyPage permissions={permissions} /> } - /> -); +const MyPage = () => { + const { permissions } = usePermissions(); + return ( + <Card> + <CardContent>Lorem ipsum sic dolor amet...</CardContent> + {permissions === 'admin' && + <CardContent>Sensitive data</CardContent> + } + </Card> + ); +} -export default MyPageWithPermissions; +export default MyPage; // in src/customRoutes.js import React from 'react'; import { Route } from 'react-router-dom'; -import Foo from './Foo'; -import Bar from './Bar'; -import Baz from './Baz'; -import MyPageWithPermissions from './MyPage'; +import MyPage from './MyPage'; export default [ - <Route exact path="/foo" component={Foo} />, - <Route exact path="/bar" component={Bar} />, - <Route exact path="/baz" component={Baz} noLayout />, - <Route exact path="/baz" component={MyPageWithPermissions} />, + <Route exact path="/baz" component={MyPage} />, ]; ``` -{% endraw %} -## Restricting Access to Content in Custom Menu +The `usePermissions` hook is optimistic: it doesn't block rendering during the `authProvider` call. In the above example, the `MyPage` component renders even before getting the response from the `authProvider`. To avoid a blink in the interface while the `authProvider` is answering, use the `loaded` return value of `usePermissions()`: -What if you want to check the permissions inside a [custom menu](./Admin.md#menu) ? Much like getting permissions inside a custom page, you'll have to use the `WithPermissions` component: +```jsx +const MyPage = () => { + const { loaded, permissions } = usePermissions(); + return loaded ? ( + <Card> + <CardContent>Lorem ipsum sic dolor amet...</CardContent> + {permissions === 'admin' && + <CardContent>Sensitive data</CardContent> + } + </Card> + ) : null; +} +``` + +## Restricting Access to a Menu + +What if you want to check the permissions inside a [custom menu](./Admin.md#menu)? Much like getting permissions inside a custom page, you'll have to use the `usePermissions` hook: -{% raw %} ```jsx // in src/myMenu.js import React from 'react'; import { connect } from 'react-redux'; -import { MenuItemLink, WithPermissions } from 'react-admin'; +import { MenuItemLink, usePermissions } from 'react-admin'; -const Menu = ({ onMenuClick, logout }) => ( +const Menu = ({ onMenuClick, logout }) => { + const { permissions } = usePermissions(); + return ( <div> <MenuItemLink to="/posts" primaryText="Posts" onClick={onMenuClick} /> <MenuItemLink to="/comments" primaryText="Comments" onClick={onMenuClick} /> - <WithPermissions - render={({ permissions }) => ( - permissions === 'admin' - ? <MenuItemLink to="/custom-route" primaryText="Miscellaneous" onClick={onMenuClick} /> - : null - )} - /> + { permissions === 'admin' && + <MenuItemLink to="/custom-route" primaryText="Miscellaneous" onClick={onMenuClick} /> + } {logout} </div> ); +} ``` -{% endraw %} diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index b17fb411aae..a1640f44303 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -627,17 +627,19 @@ </a> <ul class="articles" {% if page.path !='Authentication.md' %}style="display:none" {% endif %}> <li class="chapter"> - <a href="#configuring-the-auth-provider">Configuring the Auth Provider</a> + <a href="#the-authprovider">The <code>authProvider</code></a> + </li> + <li class="chapter"> + <a href="#login-configuration">Login Configuration</a> </li> <li class="chapter"> <a href="#sending-credentials-to-the-api">Sending Credentials to the API</a> </li> <li class="chapter"> - <a href="#adding-a-logout-button">Adding a Logout Button</a> + <a href="#logout-configuration">Logout Configuration</a> </li> <li class="chapter"> - <a href="#catching-authentication-errors-on-the-api">Catching Authentication Errors On - The API</a> + <a href="#catching-authentication-errors-on-the-api">Catching Authentication Errors</a> </li> <li class="chapter"> <a href="#checking-credentials-during-navigation">Checking Credentials During @@ -648,7 +650,7 @@ Components</a> </li> <li class="chapter"> - <a href="#restricting-access-to-a-custom-page">Restricting Access To A Custom Page</a> + <a href="#useauth-hook"><code>useAuth()</code> Hook</a> </li> </ul> </li> @@ -661,16 +663,21 @@ <a href="#configuring-the-auth-provider">Configuring the Auth Provider</a> </li> <li class="chapter"> - <a href="#restricting-access-to-resources-or-views">Restricting Access To Resources or + <a href="#restricting-access-to-resources-or-views">Restricted Resources or Views</a> </li> <li class="chapter"> - <a href="#restricting-access-to-fields-and-inputs">Restricting Access To Fields And + <a href="#restricting-access-to-fields-and-inputs">Restricted Fields And Inputs</a> </li> <li class="chapter"> - <a href="#restricting-access-to-content-in-custom-pages-or-menus">Restricting Access To - Content In Custom Pages or Menus</a> + <a href="#restricting-access-to-the-dashboard">Restricted Dashboard</a> + </li> + <li class="chapter"> + <a href="#usepermissions-hook"><code>usePermissions()</code> Hook</a> + </li> + <li class="chapter"> + <a href="#restricting-access-to-a-menu">Restricted Menu</a> </li> </ul> </li> diff --git a/examples/simple/src/customRouteLayout.js b/examples/simple/src/customRouteLayout.js index d7273e0282f..0d94bd0f02b 100644 --- a/examples/simple/src/customRouteLayout.js +++ b/examples/simple/src/customRouteLayout.js @@ -1,38 +1,23 @@ -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { crudGetList as crudGetListAction, Title } from 'react-admin'; // eslint-disable-line import/no-unresolved +import React from 'react'; +import { useGetList, useAuth, Title } from 'react-admin'; -class CustomRouteLayout extends Component { - componentWillMount() { - this.props.crudGetList( - 'posts', - { page: 0, perPage: 10 }, - { field: 'id', order: 'ASC' } - ); - } +const CustomRouteLayout = () => { + useAuth(); + const { total, loaded } = useGetList( + 'posts', + { page: 1, perPage: 10 }, + { field: 'published_at', order: 'DESC' } + ); - render() { - const { total } = this.props; + return loaded ? ( + <div> + <Title title="Example Admin" /> + <h1>Posts</h1> + <p> + Found <span className="total">{total}</span> posts ! + </p> + </div> + ) : null; +}; - return ( - <div> - <Title title="Example Admin" /> - <h1>Posts</h1> - <p> - Found <span className="total">{total}</span> posts ! - </p> - </div> - ); - } -} - -const mapStateToProps = state => ({ - total: state.admin.resources.posts - ? state.admin.resources.posts.list.total - : 0, -}); - -export default connect( - mapStateToProps, - { crudGetList: crudGetListAction } -)(CustomRouteLayout); +export default CustomRouteLayout; diff --git a/packages/ra-core/package.json b/packages/ra-core/package.json index 68703fc462c..2785a2c843b 100644 --- a/packages/ra-core/package.json +++ b/packages/ra-core/package.json @@ -30,7 +30,7 @@ "@redux-saga/testing-utils": "^1.0.2", "@types/history": "^4.7.2", "@types/node-polyglot": "^0.4.31", - "@types/react-router": "^4.4.4", + "@types/react-router": "^5.0.1", "@types/react-router-dom": "^4.3.1", "@types/recompose": "^0.27.0", "@types/redux-form": "^7.5.2", diff --git a/packages/ra-core/src/CoreAdmin.tsx b/packages/ra-core/src/CoreAdmin.tsx index 1ab5b7e0727..f74d69948de 100644 --- a/packages/ra-core/src/CoreAdmin.tsx +++ b/packages/ra-core/src/CoreAdmin.tsx @@ -7,6 +7,7 @@ import { Switch, Route } from 'react-router-dom'; import { ConnectedRouter } from 'connected-react-router'; import withContext from 'recompose/withContext'; +import AuthContext from './auth/AuthContext'; import createAdminStore from './createAdminStore'; import TranslationProvider from './i18n/TranslationProvider'; import CoreAdminRouter from './CoreAdminRouter'; @@ -55,11 +56,7 @@ interface AdminContext { authProvider: AuthProvider; } -class CoreAdminBase extends Component<AdminProps> { - static contextTypes = { - store: PropTypes.object, - }; - +class CoreAdmin extends Component<AdminProps> { static defaultProps: Partial<AdminProps> = { catchAll: () => null, layout: DefaultLayout, @@ -174,31 +171,28 @@ React-admin requires a valid dataProvider function to work.`); } = this.props; return this.reduxIsAlreadyInitialized ? ( - this.renderCore() - ) : ( - <Provider - store={createAdminStore({ - authProvider, - customReducers, - customSagas, - dataProvider, - i18nProvider, - initialState, - locale, - history: this.history, - })} - > + <AuthContext.Provider value={authProvider}> {this.renderCore()} - </Provider> + </AuthContext.Provider> + ) : ( + <AuthContext.Provider value={authProvider}> + <Provider + store={createAdminStore({ + authProvider, + customReducers, + customSagas, + dataProvider, + i18nProvider, + initialState, + locale, + history: this.history, + })} + > + {this.renderCore()} + </Provider> + </AuthContext.Provider> ); } } -const CoreAdmin = withContext<AdminContext, AdminProps>( - { - authProvider: PropTypes.func, - }, - ({ authProvider }) => ({ authProvider }) -)(CoreAdminBase) as ComponentType<AdminProps>; - export default CoreAdmin; diff --git a/packages/ra-core/src/CoreAdminRouter.spec.tsx b/packages/ra-core/src/CoreAdminRouter.spec.tsx index 2387834b37d..23d459e0a7e 100644 --- a/packages/ra-core/src/CoreAdminRouter.spec.tsx +++ b/packages/ra-core/src/CoreAdminRouter.spec.tsx @@ -1,94 +1,115 @@ import React from 'react'; -import { shallow } from 'enzyme'; -import assert from 'assert'; -import { Route } from 'react-router-dom'; +import { cleanup, wait } from 'react-testing-library'; +import expect from 'expect'; +import { Router, Route } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; +import renderWithRedux from './util/renderWithRedux'; import { CoreAdminRouter } from './CoreAdminRouter'; +import AuthContext from './auth/AuthContext'; import Resource from './Resource'; +const Layout = ({ children }) => <div>Layout {children}</div>; + describe('<AdminRouter>', () => { + afterEach(cleanup); + const defaultProps = { - authProvider: () => Promise.resolve(), userLogout: () => <span />, customRoutes: [], }; describe('With resources as regular children', () => { - it('should render all resources with a registration intent', () => { - const wrapper = shallow( - <CoreAdminRouter {...defaultProps}> - <Resource name="posts" /> - <Resource name="comments" /> - </CoreAdminRouter> - ); - - const resources = wrapper.find('ConnectFunction'); - - assert.equal(resources.length, 2); - assert.deepEqual( - resources.map(resource => resource.prop('intent')), - ['registration', 'registration'] + it('should render all resources in routes', () => { + const history = createMemoryHistory(); + const { getByText } = renderWithRedux( + <Router history={history}> + <CoreAdminRouter {...defaultProps} layout={Layout}> + <Resource + name="posts" + list={() => <span>PostList</span>} + /> + <Resource + name="comments" + list={() => <span>CommentList</span>} + /> + </CoreAdminRouter> + </Router> ); + expect(getByText('Layout')).toBeDefined(); + history.push('/posts'); + expect(getByText('PostList')).toBeDefined(); + history.push('/comments'); + expect(getByText('CommentList')).toBeDefined(); }); }); describe('With resources returned from a function as children', () => { it('should render all resources with a registration intent', async () => { - const wrapper = shallow( - <CoreAdminRouter {...defaultProps}> - {() => [ - <Resource key="posts" name="posts" />, - <Resource key="comments" name="comments" />, - null, - ]} - </CoreAdminRouter> + const history = createMemoryHistory(); + const { getByText } = renderWithRedux( + <AuthContext.Provider value={() => Promise.resolve()}> + <Router history={history}> + <CoreAdminRouter {...defaultProps} layout={Layout}> + {() => [ + <Resource + key="posts" + name="posts" + list={() => <span>PostList</span>} + />, + <Resource + key="comments" + name="comments" + list={() => <span>CommentList</span>} + />, + null, + ]} + </CoreAdminRouter> + </Router> + </AuthContext.Provider> ); - // Timeout needed because of the authProvider call - await new Promise(resolve => { - setTimeout(resolve, 10); - }); - - wrapper.update(); - const resources = wrapper.find('ConnectFunction'); - assert.equal(resources.length, 2); - assert.deepEqual( - resources.map(resource => resource.prop('intent')), - ['registration', 'registration'] - ); + await wait(); + expect(getByText('Layout')).toBeDefined(); + history.push('/posts'); + expect(getByText('PostList')).toBeDefined(); + history.push('/comments'); + expect(getByText('CommentList')).toBeDefined(); }); }); - it('should render the custom routes which do not need a layout', () => { - const Bar = () => <div>Bar</div>; - - const wrapper = shallow( - <CoreAdminRouter - customRoutes={[ - <Route - key="custom" - noLayout - exact - path="/custom" - render={() => <div>Foo</div>} - />, - <Route - key="custom2" - noLayout - exact - path="/custom2" - component={Bar} - />, - ]} - location={{ pathname: '/custom' }} - > - <Resource name="posts" /> - <Resource name="comments" /> - </CoreAdminRouter> + it('should render the custom routes with and without layout', () => { + const history = createMemoryHistory(); + const { getByText, queryByText } = renderWithRedux( + <Router history={history}> + <CoreAdminRouter + layout={Layout} + customRoutes={[ + <Route + key="foo" + noLayout + exact + path="/foo" + render={() => <div>Foo</div>} + />, + <Route + key="bar" + exact + path="/bar" + component={() => <div>Bar</div>} + />, + ]} + location={{ pathname: '/custom' }} + > + <Resource name="posts" /> + </CoreAdminRouter> + </Router> ); - - const routes = wrapper.find('Route'); - assert.equal(routes.at(0).prop('path'), '/custom'); - assert.equal(routes.at(1).prop('path'), '/custom2'); + history.push('/foo'); + expect(queryByText('Layout')).toBeNull(); + expect(getByText('Foo')).toBeDefined(); + history.push('/bar'); + expect(getByText('Layout')).toBeDefined(); + expect(getByText('Bar')).toBeDefined(); }); }); diff --git a/packages/ra-core/src/CoreAdminRouter.tsx b/packages/ra-core/src/CoreAdminRouter.tsx index 5abe43b8a1c..f744c48209c 100644 --- a/packages/ra-core/src/CoreAdminRouter.tsx +++ b/packages/ra-core/src/CoreAdminRouter.tsx @@ -7,19 +7,16 @@ import React, { CSSProperties, ReactElement, } from 'react'; -import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { Route, Switch } from 'react-router-dom'; -import compose from 'recompose/compose'; -import getContext from 'recompose/getContext'; import { AUTH_GET_PERMISSIONS } from './auth/types'; import { isLoggedIn } from './reducer'; import { userLogout as userLogoutAction } from './actions/authActions'; import RoutesWithLayout from './RoutesWithLayout'; +import AuthContext from './auth/AuthContext'; import { Dispatch, - AuthProvider, AdminChildren, CustomRoutes, CatchAllComponent, @@ -45,7 +42,6 @@ export interface AdminRouterProps extends LayoutProps { } interface EnhancedProps { - authProvider?: AuthProvider; isLoggedIn?: boolean; userLogout: Dispatch<typeof userLogoutAction>; } @@ -61,7 +57,7 @@ export class CoreAdminRouter extends Component< static defaultProps: Partial<AdminRouterProps> = { customRoutes: [], }; - + static contextType = AuthContext; state: State = { children: [] }; componentWillMount() { @@ -77,7 +73,7 @@ export class CoreAdminRouter extends Component< initializeResourcesAsync = async ( props: AdminRouterProps & EnhancedProps ) => { - const { authProvider } = props; + const authProvider = this.context; try { const permissions = await authProvider(AUTH_GET_PERMISSIONS); const resolveChildren = props.children as RenderResourcesFunction; @@ -250,12 +246,7 @@ const mapStateToProps = state => ({ isLoggedIn: isLoggedIn(state), }); -export default compose( - getContext({ - authProvider: PropTypes.func, - }), - connect( - mapStateToProps, - { userLogout: userLogoutAction } - ) +export default connect( + mapStateToProps, + { userLogout: userLogoutAction } )(CoreAdminRouter) as ComponentType<AdminRouterProps>; diff --git a/packages/ra-core/src/Resource.spec.tsx b/packages/ra-core/src/Resource.spec.tsx index 259bbc7226d..4a46d1afc07 100644 --- a/packages/ra-core/src/Resource.spec.tsx +++ b/packages/ra-core/src/Resource.spec.tsx @@ -1,8 +1,13 @@ import React from 'react'; -import assert from 'assert'; -import { shallow } from 'enzyme'; -import { Resource } from './Resource'; -import { Route } from 'react-router-dom'; +import expect from 'expect'; +import { cleanup, wait } from 'react-testing-library'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; + +import Resource from './Resource'; +import { registerResource, unregisterResource } from './actions'; +import renderWithRedux from './util/renderWithRedux'; +import AuthContext from './auth/AuthContext'; const PostList = () => <div>PostList</div>; const PostEdit = () => <div>PostEdit</div>; @@ -21,92 +26,84 @@ const resource = { }; describe('<Resource>', () => { - const registerResource = jest.fn(); - const unregisterResource = jest.fn(); + afterEach(cleanup); it(`registers its resource in redux on mount when context is 'registration'`, () => { - shallow( - <Resource - {...resource} - intent="registration" - registerResource={registerResource} - unregisterResource={unregisterResource} - /> - ); - assert.equal(registerResource.mock.calls.length, 1); - assert.deepEqual(registerResource.mock.calls[0][0], { - name: 'posts', - options: { foo: 'bar' }, - hasList: true, - hasEdit: true, - hasShow: true, - hasCreate: true, - icon: PostIcon, - }); - }); - it(`unregister its resource from redux on unmount when context is 'registration'`, () => { - const wrapper = shallow( - <Resource - {...resource} - intent="registration" - registerResource={registerResource} - unregisterResource={unregisterResource} - /> + const { dispatch } = renderWithRedux( + <Resource {...resource} intent="registration" /> ); - wrapper.unmount(); - assert.equal(unregisterResource.mock.calls.length, 1); - assert.deepEqual(unregisterResource.mock.calls[0][0], 'posts'); - }); - it('renders list route if specified', () => { - const wrapper = shallow( - <Resource - {...resource} - intent="route" - match={{ url: 'posts' }} - registerResource={registerResource} - unregisterResource={unregisterResource} - /> + expect(dispatch).toHaveBeenCalledWith( + registerResource({ + name: 'posts', + options: { foo: 'bar' }, + hasList: true, + hasEdit: true, + hasShow: true, + hasCreate: true, + icon: PostIcon, + }) ); - assert.ok(wrapper.containsMatchingElement(<Route path="posts" />)); }); - it('renders create route if specified', () => { - const wrapper = shallow( - <Resource - {...resource} - intent="route" - match={{ url: 'posts' }} - registerResource={registerResource} - unregisterResource={unregisterResource} - /> - ); - assert.ok( - wrapper.containsMatchingElement(<Route path="posts/create" />) + it(`unregister its resource from redux on unmount when context is 'registration'`, () => { + const { unmount, dispatch } = renderWithRedux( + <Resource {...resource} intent="registration" /> ); + unmount(); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch.mock.calls[1][0]).toEqual(unregisterResource('posts')); }); - it('renders edit route if specified', () => { - const wrapper = shallow( - <Resource - {...resource} - intent="route" - match={{ url: 'posts' }} - registerResource={registerResource} - unregisterResource={unregisterResource} - /> + it('renders resource routes by default', () => { + const history = createMemoryHistory(); + const { getByText } = renderWithRedux( + <Router history={history}> + <Resource + {...resource} + match={{ + url: '/posts', + params: {}, + isExact: true, + path: '/', + }} + /> + </Router>, + { admin: { resources: { posts: {} } } } ); - assert.ok(wrapper.containsMatchingElement(<Route path="posts/:id" />)); + history.push('/posts'); + expect(getByText('PostList')).toBeDefined(); + history.push('/posts/123'); + expect(getByText('PostEdit')).toBeDefined(); + history.push('/posts/123/show'); + expect(getByText('PostShow')).toBeDefined(); + history.push('/posts/create'); + expect(getByText('PostCreate')).toBeDefined(); }); - it('renders show route if specified', () => { - const wrapper = shallow( - <Resource - {...resource} - intent="route" - match={{ url: 'posts' }} - registerResource={registerResource} - unregisterResource={unregisterResource} - /> - ); - assert.ok( - wrapper.containsMatchingElement(<Route path="posts/:id/show" />) + it('injects permissions to the resource routes', async () => { + const history = createMemoryHistory(); + const authProvider = type => + type === 'AUTH_GET_PERMISSIONS' + ? Promise.resolve('admin') + : Promise.resolve(); + const { getByText } = renderWithRedux( + <AuthContext.Provider value={authProvider}> + <Router history={history}> + <Resource + name="posts" + list={({ permissions }) => ( + <span>Permissions: {permissions}</span> + )} + match={{ + url: '/posts', + params: {}, + isExact: true, + path: '/', + }} + /> + </Router> + </AuthContext.Provider>, + { admin: { resources: { posts: {} } } } ); + history.push('/posts'); + await wait(); + expect(getByText('Permissions: admin')).toBeDefined(); }); }); diff --git a/packages/ra-core/src/Resource.tsx b/packages/ra-core/src/Resource.tsx index 46c03644907..e508ac1933a 100644 --- a/packages/ra-core/src/Resource.tsx +++ b/packages/ra-core/src/Resource.tsx @@ -1,40 +1,26 @@ -import React, { createElement, Component, ComponentType } from 'react'; -import { connect } from 'react-redux'; +import React, { FunctionComponent, useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { Route, Switch } from 'react-router-dom'; -import WithPermissions from './auth/WithPermissions'; - -import { - registerResource as registerResourceAction, - unregisterResource as unregisterResourceAction, -} from './actions'; -import { Dispatch, ResourceProps, ResourceMatch } from './types'; -interface ConnectedProps { - registerResource: Dispatch<typeof registerResourceAction>; - unregisterResource: Dispatch<typeof unregisterResourceAction>; -} +import WithPermissions from './auth/WithPermissions'; +import { registerResource, unregisterResource } from './actions'; +import { ResourceProps, ResourceMatch, ReduxState } from './types'; -export class Resource extends Component<ResourceProps & ConnectedProps> { - static defaultProps = { - intent: 'route', - options: {}, - }; +const defaultOptions = {}; - componentWillMount() { - const { - intent, - name, - list, - create, - edit, - show, - options, - icon, - registerResource, - } = this.props; - - if (intent === 'registration') { - const resource = { +const ResourceRegister: FunctionComponent<ResourceProps> = ({ + name, + list, + create, + edit, + show, + icon, + options = defaultOptions, +}) => { + const dispatch = useDispatch(); + useEffect(() => { + dispatch( + registerResource({ name, options, hasList: !!list, @@ -42,36 +28,35 @@ export class Resource extends Component<ResourceProps & ConnectedProps> { hasShow: !!show, hasCreate: !!create, icon, - }; - - registerResource(resource); - } - } + }) + ); + return () => dispatch(unregisterResource(name)); + }, [dispatch, name, create, edit, icon, list, show, options]); + return null; +}; - componentWillUnmount() { - const { intent, name, unregisterResource } = this.props; - if (intent === 'registration') { - unregisterResource(name); - } - } +const ResourceRoutes: FunctionComponent<ResourceProps> = ({ + name, + match, + list, + create, + edit, + show, + options = defaultOptions, +}) => { + const isRegistered = useSelector((state: ReduxState) => + state.admin.resources[name] ? true : false + ); - render() { - const { - match, - intent, - name, - list, - create, - edit, - show, - options, - } = this.props; + const basePath = match ? match.url : ''; - if (intent === 'registration') { + // match tends to change even on the same route ; using memo to avoid an extra render + return useMemo(() => { + // if the registration hasn't finished, no need to render + if (!isRegistered) { return null; } - - const resource = { + const props = { resource: name, options, hasList: !!list, @@ -79,102 +64,81 @@ export class Resource extends Component<ResourceProps & ConnectedProps> { hasShow: !!show, hasCreate: !!create, }; - - const basePath = match.url; - return ( <Switch> {create && ( <Route - path={`${match.url}/create`} + path={`${basePath}/create`} render={routeProps => ( <WithPermissions - render={props => - createElement(create, { - basePath, - ...props, - }) - } + component={create} + basePath={basePath} {...routeProps} - {...resource} + {...props} /> )} /> )} {show && ( <Route - path={`${match.url}/:id/show`} + path={`${basePath}/:id/show`} render={routeProps => ( <WithPermissions - render={props => - createElement(show, { - basePath, - id: decodeURIComponent( - (props.match as ResourceMatch) - .params.id - ), - ...props, - }) - } + component={show} + basePath={basePath} + id={decodeURIComponent( + (routeProps.match as ResourceMatch).params + .id + )} {...routeProps} - {...resource} + {...props} /> )} /> )} {edit && ( <Route - path={`${match.url}/:id`} + path={`${basePath}/:id`} render={routeProps => ( <WithPermissions - render={props => - createElement(edit, { - basePath, - id: decodeURIComponent( - (props.match as ResourceMatch) - .params.id - ), - ...props, - }) - } + component={edit} + basePath={basePath} + id={decodeURIComponent( + (routeProps.match as ResourceMatch).params + .id + )} {...routeProps} - {...resource} + {...props} /> )} /> )} {list && ( <Route - path={`${match.url}`} + path={`${basePath}`} render={routeProps => ( <WithPermissions - render={props => - createElement(list, { - basePath, - ...props, - }) - } + component={list} + basePath={basePath} {...routeProps} - {...resource} + {...props} /> )} /> )} </Switch> ); - } -} + }, [basePath, name, create, edit, list, show, options, isRegistered]); // eslint-disable-line react-hooks/exhaustive-deps +}; -const ConnectedResource = connect( - null, - { - registerResource: registerResourceAction, - unregisterResource: unregisterResourceAction, - } -)( - // Necessary casting because of https://github.com/DefinitelyTyped/DefinitelyTyped/issues/19989#issuecomment-432752918 - Resource as ComponentType<ResourceProps & ConnectedProps> -); +const Resource: FunctionComponent<ResourceProps> = ({ + intent = 'route', + ...props +}) => + intent === 'registration' ? ( + <ResourceRegister {...props} /> + ) : ( + <ResourceRoutes {...props} /> + ); -// Necessary casting because of https://github.com/DefinitelyTyped/DefinitelyTyped/issues/19989#issuecomment-432752918 -export default ConnectedResource as ComponentType<ResourceProps>; +export default Resource; diff --git a/packages/ra-core/src/RoutesWithLayout.spec.tsx b/packages/ra-core/src/RoutesWithLayout.spec.tsx index 4ab1b985db4..c85ad4cfd3c 100644 --- a/packages/ra-core/src/RoutesWithLayout.spec.tsx +++ b/packages/ra-core/src/RoutesWithLayout.spec.tsx @@ -15,7 +15,10 @@ describe('<RoutesWithLayout>', () => { // the Provider is required because the dashboard is wrapped by <Authenticated>, which is a connected component const store = createStore(() => ({ - admin: { auth: { isLoggedIn: true } }, + admin: { + auth: { isLoggedIn: true }, + }, + router: { location: { pathname: '/' } }, })); it('should show dashboard on / when provided', () => { diff --git a/packages/ra-core/src/RoutesWithLayout.tsx b/packages/ra-core/src/RoutesWithLayout.tsx index 03aab042266..38de2af6354 100644 --- a/packages/ra-core/src/RoutesWithLayout.tsx +++ b/packages/ra-core/src/RoutesWithLayout.tsx @@ -58,8 +58,8 @@ const RoutesWithLayout: SFC<Props> = ({ authParams={{ route: 'dashboard', }} + component={dashboard} {...routeProps} - render={props => createElement(dashboard, props)} /> )} /> diff --git a/packages/ra-core/src/actions/authActions.ts b/packages/ra-core/src/actions/authActions.ts index 155666c067f..065615a2fd9 100644 --- a/packages/ra-core/src/actions/authActions.ts +++ b/packages/ra-core/src/actions/authActions.ts @@ -30,7 +30,7 @@ export interface UserCheckAction { export const userCheck = ( payload: object, pathName: string, - routeParams + routeParams: object = {} ): UserCheckAction => ({ type: USER_CHECK, payload: { diff --git a/packages/ra-core/src/auth/AuthContext.tsx b/packages/ra-core/src/auth/AuthContext.tsx new file mode 100644 index 00000000000..36612ae6aab --- /dev/null +++ b/packages/ra-core/src/auth/AuthContext.tsx @@ -0,0 +1,7 @@ +import { createContext } from 'react'; + +import { AuthProvider } from '../types'; + +const AuthContext = createContext<AuthProvider>(() => Promise.resolve()); + +export default AuthContext; diff --git a/packages/ra-core/src/auth/Authenticated.spec.tsx b/packages/ra-core/src/auth/Authenticated.spec.tsx index 35c4e2c133b..b95a2c4ceb8 100644 --- a/packages/ra-core/src/auth/Authenticated.spec.tsx +++ b/packages/ra-core/src/auth/Authenticated.spec.tsx @@ -1,38 +1,48 @@ import React from 'react'; import expect from 'expect'; -import { shallow, render } from 'enzyme'; -import { html } from 'cheerio'; +import { cleanup } from 'react-testing-library'; -import { Authenticated } from './Authenticated'; +import Authenticated from './Authenticated'; +import AuthContext from './AuthContext'; +import renderWithRedux from '../util/renderWithRedux'; describe('<Authenticated>', () => { + afterEach(cleanup); const Foo = () => <div>Foo</div>; - it('should call userCheck on mount', () => { - const userCheck = jest.fn(); - shallow( - <Authenticated location={{}} userCheck={userCheck}> - <Foo /> - </Authenticated> + it('should call authProvider on mount', () => { + const authProvider = jest.fn(() => Promise.resolve()); + renderWithRedux( + <AuthContext.Provider value={authProvider}> + <Authenticated> + <Foo /> + </Authenticated> + </AuthContext.Provider> ); - expect(userCheck.mock.calls.length).toEqual(1); + expect(authProvider).toBeCalledWith('AUTH_CHECK', { location: '/' }); }); - it('should call userCheck on update', () => { - const userCheck = jest.fn(); - const wrapper = shallow( - <Authenticated location={{}} userCheck={userCheck}> - <Foo /> - </Authenticated> + it('should call authProvider on update', () => { + const authProvider = jest.fn(() => Promise.resolve()); + const FooWrapper = props => ( + <AuthContext.Provider value={authProvider}> + <Authenticated {...props}> + <Foo /> + </Authenticated> + </AuthContext.Provider> ); - wrapper.setProps({ location: { pathname: 'foo' }, userCheck }); - expect(userCheck.mock.calls.length).toEqual(2); + const { rerender } = renderWithRedux(<FooWrapper />); + rerender(<FooWrapper authParams={{ foo: 'bar' }} />); + expect(authProvider).toBeCalledTimes(2); + expect(authProvider.mock.calls[1]).toEqual([ + 'AUTH_CHECK', + { foo: 'bar', location: '/' }, + ]); }); it('should render its child by default', () => { - const userCheck = jest.fn(); - const wrapper = render( - <Authenticated location={{}} userCheck={userCheck}> + const { queryByText } = renderWithRedux( + <Authenticated> <Foo /> </Authenticated> ); - expect(html(wrapper)).toEqual('<div>Foo</div>'); + expect(queryByText('Foo')).toBeDefined(); }); }); diff --git a/packages/ra-core/src/auth/Authenticated.tsx b/packages/ra-core/src/auth/Authenticated.tsx index 80a16242f5b..935b5f368f0 100644 --- a/packages/ra-core/src/auth/Authenticated.tsx +++ b/packages/ra-core/src/auth/Authenticated.tsx @@ -1,14 +1,11 @@ -import React, { Component, ReactElement } from 'react'; -import { connect } from 'react-redux'; +import { cloneElement, ReactElement, FunctionComponent } from 'react'; -import { userCheck as userCheckAction } from '../actions/authActions'; -import { UserCheck } from './types'; +import useAuth from './useAuth'; interface Props { - authParams?: object; children: ReactElement<any>; - location: object; - userCheck: UserCheck; + authParams?: object; + location?: object; // kept for backwards compatibility, unused } /** @@ -18,7 +15,6 @@ interface Props { * Use it to decorate your custom page components to require * authentication. * - * Pass the `location` from the `routeParams` as `location` prop. * You can set additional `authParams` at will if your authProvider * requires it. * @@ -26,8 +22,8 @@ interface Props { * import { Authenticated } from 'react-admin'; * * const CustomRoutes = [ - * <Route path="/foo" render={routeParams => - * <Authenticated location={routeParams.location} authParams={{ foo: 'bar' }}> + * <Route path="/foo" render={() => + * <Authenticated authParams={{ foo: 'bar' }}> * <Foo /> * </Authenticated> * } /> @@ -38,36 +34,16 @@ interface Props { * </Admin> * ); */ -export class Authenticated extends Component<Props> { - componentWillMount() { - this.checkAuthentication(this.props); - } - - componentWillReceiveProps(nextProps) { - if (nextProps.location !== this.props.location) { - this.checkAuthentication(nextProps); - } - } - - checkAuthentication(params) { - const { userCheck, authParams, location } = params; - userCheck(authParams, location && location.pathname); - } - +const Authenticated: FunctionComponent<Props> = ({ + authParams, + children, + location, // kept for backwards compatibility, unused + ...rest +}) => { + useAuth(authParams); // render the child even though the AUTH_CHECK isn't finished (optimistic rendering) - render() { - const { - children, - userCheck, - authParams, - location, - ...rest - } = this.props; - return React.cloneElement(children, rest); - } -} + // the above hook will log out if the authProvider doesn't validate that the user is authenticated + return cloneElement(children, rest); +}; -export default connect( - null, - { userCheck: userCheckAction } -)(Authenticated); +export default Authenticated; diff --git a/packages/ra-core/src/auth/WithPermissions.tsx b/packages/ra-core/src/auth/WithPermissions.tsx index c8adbd5e5d7..55a1e8df04a 100644 --- a/packages/ra-core/src/auth/WithPermissions.tsx +++ b/packages/ra-core/src/auth/WithPermissions.tsx @@ -1,42 +1,32 @@ -import { Children, Component, ReactNode, ComponentType } from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import compose from 'recompose/compose'; -import getContext from 'recompose/getContext'; +import { + Children, + FunctionComponent, + ReactElement, + ComponentType, + createElement, +} from 'react'; +import { Location } from 'history'; -import { userCheck as userCheckAction } from '../actions/authActions'; -import { AUTH_GET_PERMISSIONS } from './types'; -import { isLoggedIn as getIsLoggedIn } from '../reducer'; import warning from '../util/warning'; -import { AuthProvider } from '../types'; -import { UserCheck } from './types'; -import { Location } from 'history'; -import { match as Match } from 'react-router'; +import useAuth from './useAuth'; +import usePermissions from './usePermissions'; export interface WithPermissionsChildrenParams { - authParams?: object; - location?: Location; - match: Match; permissions: any; } type WithPermissionsChildren = ( params: WithPermissionsChildrenParams -) => ReactNode; +) => ReactElement; interface Props { authParams?: object; children?: WithPermissionsChildren; - location: Location; - match: Match; + component?: ComponentType<any>; + location?: Location; render?: WithPermissionsChildren; staticContext?: object; -} - -interface EnhancedProps { - authProvider: AuthProvider; - isLoggedIn: boolean; - userCheck: UserCheck; + [key: string]: any; } const isEmptyChildren = children => Children.count(children) === 0; @@ -50,7 +40,6 @@ const isEmptyChildren = children => Children.count(children) === 0; * a custom role. It will pass the permissions as a prop to your * component. * - * Pass the `location` from the `routeParams` as `location` prop. * You can set additional `authParams` at will if your authProvider * requires it. * @@ -63,11 +52,10 @@ const isEmptyChildren = children => Children.count(children) === 0; * ); * * const customRoutes = [ - * <Route path="/foo" render={routeParams => + * <Route path="/foo" render={() => * <WithPermissions - * location={routeParams.location} * authParams={{ foo: 'bar' }} - * render={props => <Foo {...props} />} + * render={({ permissions, ...props }) => <Foo permissions={permissions} {...props} />} * /> * } /> * ]; @@ -77,99 +65,36 @@ const isEmptyChildren = children => Children.count(children) === 0; * </Admin> * ); */ -export class WithPermissions extends Component<Props & EnhancedProps> { - cancelled = false; - - state = { permissions: null }; - - componentWillMount() { - warning( - this.props.render && - this.props.children && - !isEmptyChildren(this.props.children), - 'You should not use both <WithPermissions render> and <WithPermissions children>; <WithPermissions children> will be ignored' - ); - this.checkAuthentication(this.props); - } - - async componentDidMount() { - await this.checkPermissions(this.props); - } - - componentWillUnmount() { - this.cancelled = true; - } - - componentWillReceiveProps(nextProps) { - if ( - nextProps.location !== this.props.location || - nextProps.authParams !== this.props.authParams || - nextProps.isLoggedIn !== this.props.isLoggedIn - ) { - this.checkAuthentication(nextProps); - this.checkPermissions(this.props); - } - } - - checkAuthentication(params: Props & EnhancedProps) { - const { userCheck, authParams, location } = params; - userCheck(authParams, location && location.pathname); - } - - async checkPermissions(params: Props & EnhancedProps) { - const { authProvider, authParams, location, match } = params; - try { - const permissions = await authProvider(AUTH_GET_PERMISSIONS, { - ...authParams, - routeParams: match ? match.params : undefined, - location: location ? location.pathname : undefined, - }); - - if (!this.cancelled) { - this.setState({ permissions }); - } - } catch (error) { - if (!this.cancelled) { - this.setState({ permissions: null }); - } - } - } - +const WithPermissions: FunctionComponent<Props> = ({ + authParams, + children, + render, + component, + staticContext, + ...props +}) => { + warning( + (render && children && !isEmptyChildren(children)) || + (render && component) || + (component && children && !isEmptyChildren(children)), + 'You should only use one of the `component`, `render` and `children` props in <WithPermissions>' + ); + + useAuth(authParams); + const { permissions } = usePermissions(authParams); // render even though the AUTH_GET_PERMISSIONS // isn't finished (optimistic rendering) - render() { - const { - authProvider, - userCheck, - isLoggedIn, - render, - children, - staticContext, - ...props - } = this.props; - const { permissions } = this.state; - - if (render) { - return render({ permissions, ...props }); - } - - if (children) { - return children({ permissions, ...props }); - } + if (component) { + return createElement(component, { permissions, ...props }); } -} -const mapStateToProps = state => ({ - isLoggedIn: getIsLoggedIn(state), -}); - -const EnhancedWithPermissions = compose( - getContext({ - authProvider: PropTypes.func, - }), - connect( - mapStateToProps, - { userCheck: userCheckAction } - ) -)(WithPermissions); + // @deprecated + if (render) { + return render({ permissions, ...props }); + } + // @deprecated + if (children) { + return children({ permissions, ...props }); + } +}; -export default EnhancedWithPermissions as ComponentType<Props>; +export default WithPermissions as ComponentType<Props>; diff --git a/packages/ra-core/src/auth/index.ts b/packages/ra-core/src/auth/index.ts index ce3a7cb073d..74de6a38a32 100644 --- a/packages/ra-core/src/auth/index.ts +++ b/packages/ra-core/src/auth/index.ts @@ -1,4 +1,7 @@ import Authenticated from './Authenticated'; +import AuthContext from './AuthContext'; +import useAuth from './useAuth'; +import usePermissions from './usePermissions'; import WithPermissions from './WithPermissions'; export * from './types'; -export { Authenticated, WithPermissions }; +export { AuthContext, Authenticated, WithPermissions, useAuth, usePermissions }; diff --git a/packages/ra-core/src/auth/useAuth.spec.tsx b/packages/ra-core/src/auth/useAuth.spec.tsx new file mode 100644 index 00000000000..8caf5fdfa37 --- /dev/null +++ b/packages/ra-core/src/auth/useAuth.spec.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import expect from 'expect'; +import { cleanup, wait } from 'react-testing-library'; +import { replace } from 'connected-react-router'; + +import useAuth from './useAuth'; +import AuthContext from './AuthContext'; +import { showNotification } from '../actions/notificationActions'; +import renderWithRedux from '../util/renderWithRedux'; + +const UseAuth = ({ children, authParams, options }: any) => { + const res = useAuth(authParams, options); + return children(res); +}; + +const stateInpector = state => ( + <div> + <span>{state.loading && 'LOADING'}</span> + <span>{state.loaded && 'LOADED'}</span> + <span>{state.authenticated && 'AUTHENTICATED'}</span> + <span>{state.error && 'ERROR'}</span> + </div> +); + +describe('useAuth', () => { + afterEach(cleanup); + + it('should return a loading state on mount', () => { + const { queryByText } = renderWithRedux( + <UseAuth>{stateInpector}</UseAuth> + ); + expect(queryByText('LOADING')).not.toBeNull(); + expect(queryByText('LOADED')).toBeNull(); + expect(queryByText('AUTHENTICATED')).toBeNull(); + }); + + it('should return authenticated by default after a tick', async () => { + const { queryByText } = renderWithRedux( + <UseAuth>{stateInpector}</UseAuth> + ); + await wait(); + expect(queryByText('LOADING')).toBeNull(); + expect(queryByText('LOADED')).not.toBeNull(); + expect(queryByText('AUTHENTICATED')).not.toBeNull(); + }); + + it('should logout, redirect to login and show a notification after a tick if the auth fails', async () => { + const authProvider = jest.fn(type => + type === 'AUTH_CHECK' ? Promise.reject() : Promise.resolve() + ); + const { dispatch } = renderWithRedux( + <AuthContext.Provider value={authProvider}> + <UseAuth>{stateInpector}</UseAuth> + </AuthContext.Provider> + ); + await wait(); + expect(authProvider.mock.calls[0][0]).toBe('AUTH_CHECK'); + expect(authProvider.mock.calls[1][0]).toBe('AUTH_LOGOUT'); + expect(dispatch).toHaveBeenCalledTimes(2); + expect(dispatch.mock.calls[0][0]).toEqual( + replace({ pathname: '/login', state: { nextPathname: '/' } }) + ); + expect(dispatch.mock.calls[1][0]).toEqual( + showNotification('ra.auth.auth_check_error', 'warning') + ); + }); + + it('should return an error after a tick if the auth fails and logoutOnFailure is false', async () => { + const authProvider = () => Promise.reject('not good'); + const { queryByText } = renderWithRedux( + <AuthContext.Provider value={authProvider}> + <UseAuth options={{ logoutOnFailure: false }}> + {stateInpector} + </UseAuth> + </AuthContext.Provider> + ); + await wait(); + expect(queryByText('LOADING')).toBeNull(); + expect(queryByText('LOADED')).not.toBeNull(); + expect(queryByText('AUTHENTICATED')).toBeNull(); + expect(queryByText('ERROR')).not.toBeNull(); + }); +}); diff --git a/packages/ra-core/src/auth/useAuth.ts b/packages/ra-core/src/auth/useAuth.ts new file mode 100644 index 00000000000..35897b16b89 --- /dev/null +++ b/packages/ra-core/src/auth/useAuth.ts @@ -0,0 +1,134 @@ +import { useEffect, useContext } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { replace } from 'connected-react-router'; + +import AuthContext from './AuthContext'; +import { AUTH_CHECK, AUTH_LOGOUT } from './types'; +import { useSafeSetState } from '../util/hooks'; +import { showNotification } from '../actions/notificationActions'; +import { ReduxState } from '../types'; + +const getErrorMessage = (error, defaultMessage) => + typeof error === 'string' + ? error + : typeof error === 'undefined' || !error.message + ? defaultMessage + : error.message; + +interface State { + loading: boolean; + loaded: boolean; + authenticated?: boolean; + error?: any; +} + +const emptyParams = {}; + +interface Options { + logoutOnFailure: boolean; +} +const defaultOptions = { + logoutOnFailure: true, +}; + +/** + * Hook for restricting access to authenticated users + * + * Calls the authProvider asynchronously with the AUTH_CHECK verb. + * If the authProvider returns a rejected promise, logs the user out. + * + * The return value updates according to the request state: + * + * - start: { authenticated: false, loading: true, loaded: false } + * - success: { authenticated: true, loading: false, loaded: true } + * - error: { error: [error from provider], authenticated: false, loading: false, loaded: true } + * + * Useful in custom page components that require authentication. + * + * @param {Object} authParams Any params you want to pass to the authProvider + * @param {Object} options + * @param {boolean} options.logoutOnFailure Whether the user should be logged out if the authProvider fails to authenticatde them. True by default. + * + * @returns The current auth check state. Destructure as { authenticated, error, loading, loaded }. + * + * @example + * import { useAuth } from 'react-admin'; + * + * const CustomRoutes = [ + * <Route path="/foo" render={() => { + * useAuth(); + * return <Foo />; + * }} />, + * <Route path="/bar" render={() => { + * const { authenticated } = useAuth( + * { myContext: 'foobar' }, + * { logoutOnFailure: false } + * ); + * return authenticated ? <Bar /> : <BarNotAuthenticated />; + * }} />, + * ]; + * const App = () => ( + * <Admin customRoutes={customRoutes}> + * ... + * </Admin> + * ); + */ +const useAuth = ( + authParams: object = emptyParams, + options: Options = defaultOptions +) => { + const [state, setState] = useSafeSetState<State>({ + loading: true, + loaded: false, + }); + const location = useSelector((state: ReduxState) => state.router.location); + const nextPathname = location && location.pathname; + const authProvider = useContext(AuthContext); + const dispatch = useDispatch(); + useEffect(() => { + if (!authProvider) { + setState({ loading: false, loaded: true, authenticated: true }); + return; + } + authProvider(AUTH_CHECK, { + location: location ? location.pathname : undefined, + ...authParams, + }) + .then(() => { + setState({ loading: false, loaded: true, authenticated: true }); + }) + .catch(error => { + setState({ + loading: false, + loaded: true, + authenticated: false, + error, + }); + if (options.logoutOnFailure) { + authProvider(AUTH_LOGOUT); + dispatch( + replace({ + pathname: (error && error.redirectTo) || '/login', + state: { nextPathname }, + }) + ); + } + const errorMessage = getErrorMessage( + error, + 'ra.auth.auth_check_error' + ); + dispatch(showNotification(errorMessage, 'warning')); + }); + }, [ + authParams, + authProvider, + dispatch, + location, + nextPathname, + options.logoutOnFailure, + setState, + ]); // eslint-disable-line react-hooks/exhaustive-deps + return state; +}; + +export default useAuth; diff --git a/packages/ra-core/src/auth/usePermissions.spec.tsx b/packages/ra-core/src/auth/usePermissions.spec.tsx new file mode 100644 index 00000000000..003ff4bb2d0 --- /dev/null +++ b/packages/ra-core/src/auth/usePermissions.spec.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import expect from 'expect'; +import { cleanup, wait } from 'react-testing-library'; + +import usePermissions from './usePermissions'; +import AuthContext from './AuthContext'; +import renderWithRedux from '../util/renderWithRedux'; + +const UsePermissions = ({ children, authParams }: any) => { + const res = usePermissions(authParams); + return children(res); +}; + +const stateInpector = state => ( + <div> + <span>{state.loading && 'LOADING'}</span> + <span>{state.loaded && 'LOADED'}</span> + {state.permissions && <span>PERMISSIONS: {state.permissions}</span>} + <span>{state.error && 'ERROR'}</span> + </div> +); + +describe('usePermissions', () => { + afterEach(cleanup); + + it('should return a loading state on mount', () => { + const { queryByText } = renderWithRedux( + <UsePermissions>{stateInpector}</UsePermissions> + ); + expect(queryByText('LOADING')).not.toBeNull(); + expect(queryByText('LOADED')).toBeNull(); + expect(queryByText('AUTHENTICATED')).toBeNull(); + }); + + it('should return nothing by default after a tick', async () => { + const { queryByText } = renderWithRedux( + <UsePermissions>{stateInpector}</UsePermissions> + ); + await wait(); + expect(queryByText('LOADING')).toBeNull(); + expect(queryByText('LOADED')).not.toBeNull(); + }); + + it('should return the permissions after a tick', async () => { + const authProvider = () => Promise.resolve('admin'); + const { queryByText, debug } = renderWithRedux( + <AuthContext.Provider value={authProvider}> + <UsePermissions>{stateInpector}</UsePermissions> + </AuthContext.Provider> + ); + await wait(); + debug(); + expect(queryByText('LOADING')).toBeNull(); + expect(queryByText('LOADED')).not.toBeNull(); + expect(queryByText('PERMISSIONS: admin')).not.toBeNull(); + }); + + it('should return an error after a tick if the auth call fails', async () => { + const authProvider = () => Promise.reject('not good'); + const { queryByText } = renderWithRedux( + <AuthContext.Provider value={authProvider}> + <UsePermissions>{stateInpector}</UsePermissions> + </AuthContext.Provider> + ); + await wait(); + expect(queryByText('LOADING')).toBeNull(); + expect(queryByText('LOADED')).not.toBeNull(); + expect(queryByText('ERROR')).not.toBeNull(); + }); +}); diff --git a/packages/ra-core/src/auth/usePermissions.ts b/packages/ra-core/src/auth/usePermissions.ts new file mode 100644 index 00000000000..07382b93397 --- /dev/null +++ b/packages/ra-core/src/auth/usePermissions.ts @@ -0,0 +1,79 @@ +import { useEffect, useContext } from 'react'; +import { useSelector } from 'react-redux'; + +import AuthContext from './AuthContext'; +import { AUTH_GET_PERMISSIONS } from './types'; +import { useSafeSetState } from '../util/hooks'; +import { ReduxState } from '../types'; + +interface State { + loading: boolean; + loaded: boolean; + permissions?: any; + error?: any; +} + +const emptyParams = {}; + +/** + * Hook for getting user permissions + * + * Calls the authProvider asynchronously with the AUTH_GET_PERMISSIONS verb. + * If the authProvider returns a rejected promise, returns empty permissions. + * + * The return value updates according to the request state: + * + * - start: { loading: true, loaded: false } + * - success: { permissions: [any], loading: false, loaded: true } + * - error: { error: [error from provider], loading: false, loaded: true } + * + * Useful to enable features based on user permissions + * + * @param {Object} authParams Any params you want to pass to the authProvider + * + * @returns The current auth check state. Destructure as { permissions, error, loading, loaded }. + * + * @example + * import { usePermissions } from 'react-admin'; + * + * const PostDetail = props => { + * const { loaded, permissions } = usePermissions(); + * if (loaded && permissions == 'editor') { + * return <PostEdit {...props} /> + * } else { + * return <PostShow {...props} /> + * } + * }; + */ +const usePermissions = (authParams = emptyParams) => { + const [state, setState] = useSafeSetState<State>({ + loading: true, + loaded: false, + }); + const location = useSelector((state: ReduxState) => state.router.location); + const pathname = location && location.pathname; + const authProvider = useContext(AuthContext); + useEffect(() => { + if (!authProvider) { + setState({ loading: false, loaded: true }); + return; + } + authProvider(AUTH_GET_PERMISSIONS, { + location: pathname, + ...authParams, + }) + .then(permissions => { + setState({ loading: false, loaded: true, permissions }); + }) + .catch(error => { + setState({ + loading: false, + loaded: true, + error, + }); + }); + }, [authParams, authProvider, pathname]); // eslint-disable-line react-hooks/exhaustive-deps + return state; +}; + +export default usePermissions; diff --git a/packages/ra-core/src/fetch/useMutation.ts b/packages/ra-core/src/fetch/useMutation.ts index 2d3e199bafa..d8e6c0a6ae3 100644 --- a/packages/ra-core/src/fetch/useMutation.ts +++ b/packages/ra-core/src/fetch/useMutation.ts @@ -1,6 +1,6 @@ import { useCallback } from 'react'; -import { useSafeSetState } from './hooks'; +import { useSafeSetState } from '../util/hooks'; import useDataProvider from './useDataProvider'; export interface Query { diff --git a/packages/ra-core/src/fetch/useQuery.ts b/packages/ra-core/src/fetch/useQuery.ts index 5357a2b1858..e68c48f2f23 100644 --- a/packages/ra-core/src/fetch/useQuery.ts +++ b/packages/ra-core/src/fetch/useQuery.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import { useSafeSetState } from './hooks'; +import { useSafeSetState } from '../util/hooks'; import useDataProvider from './useDataProvider'; export interface Query { diff --git a/packages/ra-core/src/fetch/useQueryWithStore.ts b/packages/ra-core/src/fetch/useQueryWithStore.ts index b5d9ac04930..6c7f5ad56e8 100644 --- a/packages/ra-core/src/fetch/useQueryWithStore.ts +++ b/packages/ra-core/src/fetch/useQueryWithStore.ts @@ -4,7 +4,7 @@ import { useSelector } from 'react-redux'; import isEqual from 'lodash/isEqual'; import { ReduxState } from '../types'; -import { useSafeSetState } from './hooks'; +import { useSafeSetState } from '../util/hooks'; import useDataProvider from './useDataProvider'; export interface Query { diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index de1f45dea6e..6e2baf5131a 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -1,5 +1,6 @@ import { ReactNode, ReactElement, ComponentType } from 'react'; import { RouteProps, RouteComponentProps, match as Match } from 'react-router'; +import { Location } from 'history'; import { WithPermissionsChildrenParams } from './auth/WithPermissions'; @@ -74,6 +75,9 @@ export interface ReduxState { locale: string; messages: object; }; + router: { + location: Location; + }; } export type Dispatch<T> = T extends (...args: infer A) => any @@ -112,12 +116,14 @@ export interface LayoutProps { export type LayoutComponent = ComponentType<LayoutProps>; -interface ReactAdminComponentProps { +export interface ReactAdminComponentProps { basePath: string; + permissions?: any; } -interface ReactAdminComponentPropsWithId { - id: Identifier; +export interface ReactAdminComponentPropsWithId { basePath: string; + permissions?: any; + id: Identifier; } export type ResourceMatch = Match<{ @@ -125,7 +131,7 @@ export type ResourceMatch = Match<{ }>; export interface ResourceProps { - intent: 'route' | 'registration'; + intent?: 'route' | 'registration'; match?: ResourceMatch; name: string; list?: ComponentType<ReactAdminComponentProps>; @@ -133,5 +139,5 @@ export interface ResourceProps { edit?: ComponentType<ReactAdminComponentPropsWithId>; show?: ComponentType<ReactAdminComponentPropsWithId>; icon?: ComponentType<any>; - options: object; + options?: object; } diff --git a/packages/ra-core/src/fetch/hooks.ts b/packages/ra-core/src/util/hooks.ts similarity index 74% rename from packages/ra-core/src/fetch/hooks.ts rename to packages/ra-core/src/util/hooks.ts index a3cb836b2b6..83e6c178b69 100644 --- a/packages/ra-core/src/fetch/hooks.ts +++ b/packages/ra-core/src/util/hooks.ts @@ -1,9 +1,9 @@ -import { useState, useRef, useEffect } from 'react'; +import { useState, useRef, useEffect, useCallback } from 'react'; import isEqual from 'lodash/isEqual'; // thanks Kent C Dodds for the following helpers -export function useSafeSetState(initialState) { +export function useSafeSetState<T>(initialState): [T, (args: any) => void] { const [state, setState] = useState(initialState); const mountedRef = useRef(false); @@ -11,7 +11,10 @@ export function useSafeSetState(initialState) { mountedRef.current = true; return () => (mountedRef.current = false); }, []); - const safeSetState = args => mountedRef.current && setState(args); + const safeSetState = useCallback( + args => mountedRef.current && setState(args), + [mountedRef, setState] + ); return [state, safeSetState]; } diff --git a/yarn.lock b/yarn.lock index b0bc457fabd..390d543cfc8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2040,7 +2040,7 @@ "@types/react" "*" "@types/react-router" "*" -"@types/react-router@*", "@types/react-router@^4.4.4": +"@types/react-router@*": version "4.4.4" resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-4.4.4.tgz#4dbd5588ea6024e0c04519bd8aabe74c0a2b77e5" integrity sha512-TZVfpT6nvUv/lbho/nRtckEtgkhspOQr3qxrnpXixwgQRKKyg5PvDfNKc8Uend/p/Pi70614VCmC0NPAKWF+0g== @@ -2048,6 +2048,14 @@ "@types/history" "*" "@types/react" "*" +"@types/react-router@^5.0.1": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.0.2.tgz#619850cf28245d97bfa205f1fa7136451ba384bc" + integrity sha512-sdMN284GEOcqDEMS/hE/XD06Abw2fws30+xkZf3C9cSRcWopiv/HDTmunYI7DKLYKVRaWFkq1lkuJ6qeYu0E7A== + dependencies: + "@types/history" "*" + "@types/react" "*" + "@types/react-transition-group@^2.0.16": version "2.9.1" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-2.9.1.tgz#66c9ca5d0b20bae72fe6b797e0d362b996d55e9f"