From eef26707cb27337faebd88f7069fcf1a983a1896 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Wed, 26 Jun 2019 09:18:11 +0200 Subject: [PATCH 01/18] Add useAuth hook --- packages/ra-core/package.json | 2 +- packages/ra-core/src/actions/authActions.ts | 2 +- .../ra-core/src/auth/Authenticated.spec.tsx | 35 ++++++------ packages/ra-core/src/auth/Authenticated.tsx | 57 ++++++------------- packages/ra-core/src/auth/index.ts | 3 +- packages/ra-core/src/auth/useAuth.tsx | 39 +++++++++++++ packages/ra-core/src/types.ts | 4 ++ yarn.lock | 10 +++- 8 files changed, 91 insertions(+), 61 deletions(-) create mode 100644 packages/ra-core/src/auth/useAuth.tsx 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/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/Authenticated.spec.tsx b/packages/ra-core/src/auth/Authenticated.spec.tsx index 35c4e2c133b..0a59554033b 100644 --- a/packages/ra-core/src/auth/Authenticated.spec.tsx +++ b/packages/ra-core/src/auth/Authenticated.spec.tsx @@ -1,38 +1,41 @@ 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 { userCheck } from '../actions/authActions'; +import renderWithRedux from '../util/renderWithRedux'; describe('', () => { + afterEach(cleanup); const Foo = () =>
Foo
; it('should call userCheck on mount', () => { - const userCheck = jest.fn(); - shallow( - + const { dispatch } = renderWithRedux( + ); - expect(userCheck.mock.calls.length).toEqual(1); + expect(dispatch).toBeCalledWith(userCheck({}, '/')); }); it('should call userCheck on update', () => { - const userCheck = jest.fn(); - const wrapper = shallow( - + const FooWrapper = props => ( + ); - wrapper.setProps({ location: { pathname: 'foo' }, userCheck }); - expect(userCheck.mock.calls.length).toEqual(2); + const { dispatch, rerender } = renderWithRedux(); + rerender(); + expect(dispatch).toBeCalledTimes(2); + expect(dispatch.mock.calls[1][0]).toEqual( + userCheck({ foo: 'bar' }, '/') + ); }); it('should render its child by default', () => { - const userCheck = jest.fn(); - const wrapper = render( - + const { queryByText } = renderWithRedux( + ); - expect(html(wrapper)).toEqual('
Foo
'); + expect(queryByText('Foo')).toBeDefined(); }); }); diff --git a/packages/ra-core/src/auth/Authenticated.tsx b/packages/ra-core/src/auth/Authenticated.tsx index 80a16242f5b..ac963224a1d 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; - 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 = [ - * - * + * + * * * * } /> @@ -38,36 +34,15 @@ interface Props { * * ); */ -export class Authenticated extends Component { - 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 = ({ + authParams, + children, + location, + ...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); - } -} + return cloneElement(children, rest); +}; -export default connect( - null, - { userCheck: userCheckAction } -)(Authenticated); +export default Authenticated; diff --git a/packages/ra-core/src/auth/index.ts b/packages/ra-core/src/auth/index.ts index ce3a7cb073d..a72bafc9673 100644 --- a/packages/ra-core/src/auth/index.ts +++ b/packages/ra-core/src/auth/index.ts @@ -1,4 +1,5 @@ import Authenticated from './Authenticated'; +import useAuth from './useAuth'; import WithPermissions from './WithPermissions'; export * from './types'; -export { Authenticated, WithPermissions }; +export { Authenticated, WithPermissions, useAuth }; diff --git a/packages/ra-core/src/auth/useAuth.tsx b/packages/ra-core/src/auth/useAuth.tsx new file mode 100644 index 00000000000..8de848da974 --- /dev/null +++ b/packages/ra-core/src/auth/useAuth.tsx @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import { ReduxState } from '../types'; +import { userCheck } from '../actions/authActions'; + +/** + * Hook for restricting access to authenticated users + * + * Useful for Route components ; used internally by Resource. + * To be called in custom page components to require authentication. + * + * You can set additional `authParams` at will if your authProvider + * requires it. + * + * @example + * import { useAuth } from 'react-admin'; + * + * const CustomRoutes = [ + * { + * useAuth({ myContext: 'foo' }); + * return ; + * }} /> + * ]; + * const App = () => ( + * + * ... + * + * ); + */ +const useAuth = authParams => { + const location = useSelector((state: ReduxState) => state.router.location); + const dispatch = useDispatch(); + useEffect(() => { + dispatch(userCheck(authParams, location && location.pathname)); + }, [authParams, dispatch, location]); +}; + +export default useAuth; diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index de1f45dea6e..f6be10c352b 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 extends (...args: infer A) => any 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" From c9b84f459d152fe52ae401a83997d514a1730458 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Wed, 26 Jun 2019 18:49:36 +0200 Subject: [PATCH 02/18] [BC Break] Store authProvider in modern context rather than legacy context --- packages/ra-core/src/CoreAdmin.tsx | 48 +++--- packages/ra-core/src/CoreAdminRouter.spec.tsx | 157 ++++++++++-------- packages/ra-core/src/CoreAdminRouter.tsx | 21 +-- packages/ra-core/src/auth/AuthContext.tsx | 7 + packages/ra-core/src/auth/WithPermissions.tsx | 30 ++-- packages/ra-core/src/auth/index.ts | 3 +- packages/ra-core/src/auth/useAuth.tsx | 7 + packages/ra-core/src/types.ts | 4 +- 8 files changed, 145 insertions(+), 132 deletions(-) create mode 100644 packages/ra-core/src/auth/AuthContext.tsx 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 { - static contextTypes = { - store: PropTypes.object, - }; - +class CoreAdmin extends Component { static defaultProps: Partial = { catchAll: () => null, layout: DefaultLayout, @@ -174,31 +171,28 @@ React-admin requires a valid dataProvider function to work.`); } = this.props; return this.reduxIsAlreadyInitialized ? ( - this.renderCore() - ) : ( - + {this.renderCore()} - + + ) : ( + + + {this.renderCore()} + + ); } } -const CoreAdmin = withContext( - { - authProvider: PropTypes.func, - }, - ({ authProvider }) => ({ authProvider }) -)(CoreAdminBase) as ComponentType; - export default CoreAdmin; diff --git a/packages/ra-core/src/CoreAdminRouter.spec.tsx b/packages/ra-core/src/CoreAdminRouter.spec.tsx index 2387834b37d..8bb03a20089 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 }) =>
Layout {children}
; + describe('', () => { + afterEach(cleanup); + const defaultProps = { - authProvider: () => Promise.resolve(), userLogout: () => , customRoutes: [], }; describe('With resources as regular children', () => { - it('should render all resources with a registration intent', () => { - const wrapper = shallow( - - - - - ); - - 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( + + + PostList} + /> + CommentList} + /> + + ); + 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( - - {() => [ - , - , - null, - ]} - + const history = createMemoryHistory(); + const { getByText } = renderWithRedux( + Promise.resolve()}> + + + {() => [ + PostList} + />, + CommentList} + />, + null, + ]} + + + ); - // 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 = () =>
Bar
; - - const wrapper = shallow( -
Foo
} - />, - , - ]} - location={{ pathname: '/custom' }} - > - - -
+ it('should render the custom routes with and withoutayout', () => { + const history = createMemoryHistory(); + const { getByText, queryByText } = renderWithRedux( + +
Foo
} + />, +
Bar
} + />, + ]} + location={{ pathname: '/custom' }} + > + +
+
); - - 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; } @@ -61,7 +57,7 @@ export class CoreAdminRouter extends Component< static defaultProps: Partial = { 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; diff --git a/packages/ra-core/src/auth/AuthContext.tsx b/packages/ra-core/src/auth/AuthContext.tsx new file mode 100644 index 00000000000..5cbaa618de4 --- /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(() => null); + +export default AuthContext; diff --git a/packages/ra-core/src/auth/WithPermissions.tsx b/packages/ra-core/src/auth/WithPermissions.tsx index c8adbd5e5d7..e143bcacd6d 100644 --- a/packages/ra-core/src/auth/WithPermissions.tsx +++ b/packages/ra-core/src/auth/WithPermissions.tsx @@ -1,17 +1,13 @@ 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 { Location } from 'history'; +import { match as Match } from 'react-router'; +import { AUTH_GET_PERMISSIONS, UserCheck } from './types'; +import AuthContext from './AuthContext'; 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'; export interface WithPermissionsChildrenParams { authParams?: object; @@ -34,7 +30,6 @@ interface Props { } interface EnhancedProps { - authProvider: AuthProvider; isLoggedIn: boolean; userCheck: UserCheck; } @@ -78,6 +73,7 @@ const isEmptyChildren = children => Children.count(children) === 0; * ); */ export class WithPermissions extends Component { + static contextType = AuthContext; cancelled = false; state = { permissions: null }; @@ -117,7 +113,8 @@ export class WithPermissions extends Component { } async checkPermissions(params: Props & EnhancedProps) { - const { authProvider, authParams, location, match } = params; + const authProvider = this.context; + const { authParams, location, match } = params; try { const permissions = await authProvider(AUTH_GET_PERMISSIONS, { ...authParams, @@ -138,8 +135,8 @@ export class WithPermissions extends Component { // render even though the AUTH_GET_PERMISSIONS // isn't finished (optimistic rendering) render() { + const authProvider = this.context; const { - authProvider, userCheck, isLoggedIn, render, @@ -162,14 +159,9 @@ const mapStateToProps = state => ({ isLoggedIn: getIsLoggedIn(state), }); -const EnhancedWithPermissions = compose( - getContext({ - authProvider: PropTypes.func, - }), - connect( - mapStateToProps, - { userCheck: userCheckAction } - ) +const EnhancedWithPermissions = connect( + mapStateToProps, + { userCheck: userCheckAction } )(WithPermissions); export default EnhancedWithPermissions as ComponentType; diff --git a/packages/ra-core/src/auth/index.ts b/packages/ra-core/src/auth/index.ts index a72bafc9673..af7b575c3be 100644 --- a/packages/ra-core/src/auth/index.ts +++ b/packages/ra-core/src/auth/index.ts @@ -1,5 +1,6 @@ import Authenticated from './Authenticated'; +import AuthContext from './AuthContext'; import useAuth from './useAuth'; import WithPermissions from './WithPermissions'; export * from './types'; -export { Authenticated, WithPermissions, useAuth }; +export { AuthContext, Authenticated, WithPermissions, useAuth }; diff --git a/packages/ra-core/src/auth/useAuth.tsx b/packages/ra-core/src/auth/useAuth.tsx index 8de848da974..b7ef23207cf 100644 --- a/packages/ra-core/src/auth/useAuth.tsx +++ b/packages/ra-core/src/auth/useAuth.tsx @@ -7,6 +7,11 @@ import { userCheck } from '../actions/authActions'; /** * Hook for restricting access to authenticated users * + * Calls the authProvider asynchronously with the AUTH_CHECK verb. + * If the authProvider returns a failed promise, logs the user out. + * + * The hook returns nothing. + * * Useful for Route components ; used internally by Resource. * To be called in custom page components to require authentication. * @@ -27,6 +32,8 @@ import { userCheck } from '../actions/authActions'; * ... * * ); + * + * @returns void */ const useAuth = authParams => { const location = useSelector((state: ReduxState) => state.router.location); diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index f6be10c352b..480a3b0c9f1 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -129,7 +129,7 @@ export type ResourceMatch = Match<{ }>; export interface ResourceProps { - intent: 'route' | 'registration'; + intent?: 'route' | 'registration'; match?: ResourceMatch; name: string; list?: ComponentType; @@ -137,5 +137,5 @@ export interface ResourceProps { edit?: ComponentType; show?: ComponentType; icon?: ComponentType; - options: object; + options?: object; } From 3a77fe8bc8cba2ae9df957533ca7218e27656d65 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Wed, 26 Jun 2019 23:00:39 +0200 Subject: [PATCH 03/18] Move utility hooks --- packages/ra-core/src/fetch/useMutation.ts | 2 +- packages/ra-core/src/fetch/useQuery.ts | 2 +- packages/ra-core/src/fetch/useQueryWithStore.ts | 2 +- packages/ra-core/src/{fetch => util}/hooks.ts | 9 ++++++--- 4 files changed, 9 insertions(+), 6 deletions(-) rename packages/ra-core/src/{fetch => util}/hooks.ts (74%) 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/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(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]; } From 17a9a7cedb564088b1efa1bc2984ade98e4bb5a6 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Wed, 26 Jun 2019 23:01:02 +0200 Subject: [PATCH 04/18] useAuth no longer uses Redux and saga Now it manages side effects on its own and returns an authenticated state. Auto log out on failure can be disabled --- examples/simple/src/customRouteLayout.js | 22 ++++-- packages/ra-core/src/auth/Authenticated.tsx | 3 +- packages/ra-core/src/auth/useAuth.tsx | 86 +++++++++++++++++---- 3 files changed, 87 insertions(+), 24 deletions(-) diff --git a/examples/simple/src/customRouteLayout.js b/examples/simple/src/customRouteLayout.js index d7273e0282f..57228f7edf3 100644 --- a/examples/simple/src/customRouteLayout.js +++ b/examples/simple/src/customRouteLayout.js @@ -1,6 +1,10 @@ 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 { + crudGetList as crudGetListAction, + Title, + Authenticated, +} from 'react-admin'; class CustomRouteLayout extends Component { componentWillMount() { @@ -15,13 +19,15 @@ class CustomRouteLayout extends Component { const { total } = this.props; return ( -
- - <h1>Posts</h1> - <p> - Found <span className="total">{total}</span> posts ! - </p> - </div> + <Authenticated> + <div> + <Title title="Example Admin" /> + <h1>Posts</h1> + <p> + Found <span className="total">{total}</span> posts ! + </p> + </div> + </Authenticated> ); } } diff --git a/packages/ra-core/src/auth/Authenticated.tsx b/packages/ra-core/src/auth/Authenticated.tsx index ac963224a1d..935b5f368f0 100644 --- a/packages/ra-core/src/auth/Authenticated.tsx +++ b/packages/ra-core/src/auth/Authenticated.tsx @@ -37,11 +37,12 @@ interface Props { const Authenticated: FunctionComponent<Props> = ({ authParams, children, - location, + location, // kept for backwards compatibility, unused ...rest }) => { useAuth(authParams); // render the child even though the AUTH_CHECK isn't finished (optimistic rendering) + // the above hook will log out if the authProvider doesn't validate that the user is authenticated return cloneElement(children, rest); }; diff --git a/packages/ra-core/src/auth/useAuth.tsx b/packages/ra-core/src/auth/useAuth.tsx index b7ef23207cf..283cd4b37f4 100644 --- a/packages/ra-core/src/auth/useAuth.tsx +++ b/packages/ra-core/src/auth/useAuth.tsx @@ -1,29 +1,54 @@ -import { useEffect } from 'react'; +import { useEffect, useContext } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { push, 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'; -import { userCheck } from '../actions/authActions'; + +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 = {}; /** * Hook for restricting access to authenticated users * * Calls the authProvider asynchronously with the AUTH_CHECK verb. - * If the authProvider returns a failed promise, logs the user out. + * If the authProvider returns a rejected promise, logs the user out. * - * The hook returns nothing. + * The return value updates according to the request state: * - * Useful for Route components ; used internally by Resource. - * To be called in custom page components to require authentication. + * - 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 } * - * You can set additional `authParams` at will if your authProvider - * requires it. + * Useful in custom page components that require authentication. + * + * @param {Object} authParams Any params you want to pass to the authProvider + * @param {boolean} 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={routeParams => { - * useAuth({ myContext: 'foo' }); + * <Route path="/foo" render={() => { + * useAuth({ myContext: 'foobar' }); * return <Foo />; * }} /> * ]; @@ -32,15 +57,46 @@ import { userCheck } from '../actions/authActions'; * ... * </Admin> * ); - * - * @returns void */ -const useAuth = authParams => { +const useAuth = (authParams = emptyParams, logoutOnFailure = true) => { + const [state, setState] = useSafeSetState<State>({ + loading: true, + loaded: false, + authenticated: false, + }); const location = useSelector((state: ReduxState) => state.router.location); + const nextPathname = location && location.pathname; + const authProvider = useContext(AuthContext); const dispatch = useDispatch(); useEffect(() => { - dispatch(userCheck(authParams, location && location.pathname)); - }, [authParams, dispatch, location]); + authProvider(AUTH_CHECK, authParams) + .then(() => { + setState({ loading: false, loaded: true, authenticated: true }); + }) + .catch(error => { + setState({ + loading: false, + loaded: true, + authenticated: false, + error, + }); + if (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, nextPathname, logoutOnFailure]); // eslint-disable-line react-hooks/exhaustive-deps + return state; }; export default useAuth; From e143ca4fe7f5cbc86c8b9392b271867dbc8c8662 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Wed, 26 Jun 2019 23:22:23 +0200 Subject: [PATCH 05/18] use useAuth instead of Authenticated in simple example --- examples/simple/src/customRouteLayout.js | 61 ++++++++---------------- packages/ra-core/src/auth/useAuth.tsx | 8 +++- 2 files changed, 26 insertions(+), 43 deletions(-) diff --git a/examples/simple/src/customRouteLayout.js b/examples/simple/src/customRouteLayout.js index 57228f7edf3..0d94bd0f02b 100644 --- a/examples/simple/src/customRouteLayout.js +++ b/examples/simple/src/customRouteLayout.js @@ -1,44 +1,23 @@ -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { - crudGetList as crudGetListAction, - Title, - Authenticated, -} from 'react-admin'; +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 ( - <Authenticated> - <div> - <Title title="Example Admin" /> - <h1>Posts</h1> - <p> - Found <span className="total">{total}</span> posts ! - </p> - </div> - </Authenticated> - ); - } -} - -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/src/auth/useAuth.tsx b/packages/ra-core/src/auth/useAuth.tsx index 283cd4b37f4..b6c9b630f1c 100644 --- a/packages/ra-core/src/auth/useAuth.tsx +++ b/packages/ra-core/src/auth/useAuth.tsx @@ -48,9 +48,13 @@ const emptyParams = {}; * * const CustomRoutes = [ * <Route path="/foo" render={() => { - * useAuth({ myContext: 'foobar' }); + * useAuth(); * return <Foo />; - * }} /> + * }} />, + * <Route path="/bar" render={() => { + * const { authenticated } = useAuth({ myContext: 'foobar' }, false); + * return authenticated ? <Bar /> : <BarNotAuthenticated />; + * }} />, * ]; * const App = () => ( * <Admin customRoutes={customRoutes}> From 78bfa017812f6e2d6dacf1046b04e8d12d7ceb07 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Wed, 26 Jun 2019 23:40:02 +0200 Subject: [PATCH 06/18] Fix Authenticated unit tests --- packages/ra-core/src/auth/AuthContext.tsx | 2 +- .../ra-core/src/auth/Authenticated.spec.tsx | 36 +++++++++++-------- packages/ra-core/src/auth/useAuth.tsx | 4 +++ 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/packages/ra-core/src/auth/AuthContext.tsx b/packages/ra-core/src/auth/AuthContext.tsx index 5cbaa618de4..36612ae6aab 100644 --- a/packages/ra-core/src/auth/AuthContext.tsx +++ b/packages/ra-core/src/auth/AuthContext.tsx @@ -2,6 +2,6 @@ import { createContext } from 'react'; import { AuthProvider } from '../types'; -const AuthContext = createContext<AuthProvider>(() => null); +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 0a59554033b..3a17ddee77d 100644 --- a/packages/ra-core/src/auth/Authenticated.spec.tsx +++ b/packages/ra-core/src/auth/Authenticated.spec.tsx @@ -4,31 +4,39 @@ import { cleanup } from 'react-testing-library'; import Authenticated from './Authenticated'; import { userCheck } from '../actions/authActions'; +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 { dispatch } = renderWithRedux( - <Authenticated> - <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(dispatch).toBeCalledWith(userCheck({}, '/')); + expect(authProvider).toBeCalledWith('AUTH_CHECK', {}); }); it('should call userCheck on update', () => { + const authProvider = jest.fn(() => Promise.resolve()); const FooWrapper = props => ( - <Authenticated {...props}> - <Foo /> - </Authenticated> + <AuthContext.Provider value={authProvider}> + <Authenticated {...props}> + <Foo /> + </Authenticated> + </AuthContext.Provider> ); - const { dispatch, rerender } = renderWithRedux(<FooWrapper />); + const { rerender } = renderWithRedux(<FooWrapper />); rerender(<FooWrapper authParams={{ foo: 'bar' }} />); - expect(dispatch).toBeCalledTimes(2); - expect(dispatch.mock.calls[1][0]).toEqual( - userCheck({ foo: 'bar' }, '/') - ); + expect(authProvider).toBeCalledTimes(2); + expect(authProvider.mock.calls[1]).toEqual([ + 'AUTH_CHECK', + { foo: 'bar' }, + ]); }); it('should render its child by default', () => { const { queryByText } = renderWithRedux( diff --git a/packages/ra-core/src/auth/useAuth.tsx b/packages/ra-core/src/auth/useAuth.tsx index b6c9b630f1c..18287f50b57 100644 --- a/packages/ra-core/src/auth/useAuth.tsx +++ b/packages/ra-core/src/auth/useAuth.tsx @@ -73,6 +73,10 @@ const useAuth = (authParams = emptyParams, logoutOnFailure = true) => { const authProvider = useContext(AuthContext); const dispatch = useDispatch(); useEffect(() => { + if (!authProvider) { + setState({ loading: false, loaded: true, authenticated: true }); + return; + } authProvider(AUTH_CHECK, authParams) .then(() => { setState({ loading: false, loaded: true, authenticated: true }); From 594f2ebfdd80d63e048f42a195bcdce2f053f23b Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Thu, 27 Jun 2019 15:23:28 +0200 Subject: [PATCH 07/18] Add useAuth unit tests --- packages/ra-core/src/CoreAdminRouter.spec.tsx | 2 +- .../ra-core/src/auth/Authenticated.spec.tsx | 3 +- packages/ra-core/src/auth/useAuth.spec.tsx | 81 +++++++++++++++++++ packages/ra-core/src/auth/useAuth.tsx | 3 +- 4 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 packages/ra-core/src/auth/useAuth.spec.tsx diff --git a/packages/ra-core/src/CoreAdminRouter.spec.tsx b/packages/ra-core/src/CoreAdminRouter.spec.tsx index 8bb03a20089..23d459e0a7e 100644 --- a/packages/ra-core/src/CoreAdminRouter.spec.tsx +++ b/packages/ra-core/src/CoreAdminRouter.spec.tsx @@ -78,7 +78,7 @@ describe('<AdminRouter>', () => { }); }); - it('should render the custom routes with and withoutayout', () => { + it('should render the custom routes with and without layout', () => { const history = createMemoryHistory(); const { getByText, queryByText } = renderWithRedux( <Router history={history}> diff --git a/packages/ra-core/src/auth/Authenticated.spec.tsx b/packages/ra-core/src/auth/Authenticated.spec.tsx index 3a17ddee77d..e183fbfcd8f 100644 --- a/packages/ra-core/src/auth/Authenticated.spec.tsx +++ b/packages/ra-core/src/auth/Authenticated.spec.tsx @@ -3,7 +3,6 @@ import expect from 'expect'; import { cleanup } from 'react-testing-library'; import Authenticated from './Authenticated'; -import { userCheck } from '../actions/authActions'; import AuthContext from './AuthContext'; import renderWithRedux from '../util/renderWithRedux'; @@ -21,7 +20,7 @@ describe('<Authenticated>', () => { ); expect(authProvider).toBeCalledWith('AUTH_CHECK', {}); }); - it('should call userCheck on update', () => { + it('should call authProvider on update', () => { const authProvider = jest.fn(() => Promise.resolve()); const FooWrapper = props => ( <AuthContext.Provider value={authProvider}> 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..776b7222d7a --- /dev/null +++ b/packages/ra-core/src/auth/useAuth.spec.tsx @@ -0,0 +1,81 @@ +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, logoutOnFailure }: any) => { + const res = useAuth(authParams, logoutOnFailure); + 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 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.tsx b/packages/ra-core/src/auth/useAuth.tsx index 18287f50b57..0cd6eb614b4 100644 --- a/packages/ra-core/src/auth/useAuth.tsx +++ b/packages/ra-core/src/auth/useAuth.tsx @@ -1,6 +1,6 @@ import { useEffect, useContext } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { push, replace } from 'connected-react-router'; +import { replace } from 'connected-react-router'; import AuthContext from './AuthContext'; import { AUTH_CHECK, AUTH_LOGOUT } from './types'; @@ -66,7 +66,6 @@ const useAuth = (authParams = emptyParams, logoutOnFailure = true) => { const [state, setState] = useSafeSetState<State>({ loading: true, loaded: false, - authenticated: false, }); const location = useSelector((state: ReduxState) => state.router.location); const nextPathname = location && location.pathname; From 983a56343ea1304ea2ae2ccf9767cc4ca384533b Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Thu, 27 Jun 2019 15:24:17 +0200 Subject: [PATCH 08/18] Fix useAuth file name --- packages/ra-core/src/auth/{useAuth.tsx => useAuth.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/ra-core/src/auth/{useAuth.tsx => useAuth.ts} (100%) diff --git a/packages/ra-core/src/auth/useAuth.tsx b/packages/ra-core/src/auth/useAuth.ts similarity index 100% rename from packages/ra-core/src/auth/useAuth.tsx rename to packages/ra-core/src/auth/useAuth.ts From e8230ed928ea79d3618d1ae18c7eef2d57baaa83 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Thu, 27 Jun 2019 15:55:57 +0200 Subject: [PATCH 09/18] add usePermissions hook --- packages/ra-core/src/auth/index.ts | 3 +- packages/ra-core/src/auth/useAuth.ts | 7 +- .../ra-core/src/auth/usePermissions.spec.tsx | 70 ++++++++++++++++ packages/ra-core/src/auth/usePermissions.ts | 79 +++++++++++++++++++ 4 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 packages/ra-core/src/auth/usePermissions.spec.tsx create mode 100644 packages/ra-core/src/auth/usePermissions.ts diff --git a/packages/ra-core/src/auth/index.ts b/packages/ra-core/src/auth/index.ts index af7b575c3be..74de6a38a32 100644 --- a/packages/ra-core/src/auth/index.ts +++ b/packages/ra-core/src/auth/index.ts @@ -1,6 +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 { AuthContext, Authenticated, WithPermissions, useAuth }; +export { AuthContext, Authenticated, WithPermissions, useAuth, usePermissions }; diff --git a/packages/ra-core/src/auth/useAuth.ts b/packages/ra-core/src/auth/useAuth.ts index 0cd6eb614b4..4c88f05ae31 100644 --- a/packages/ra-core/src/auth/useAuth.ts +++ b/packages/ra-core/src/auth/useAuth.ts @@ -18,7 +18,7 @@ const getErrorMessage = (error, defaultMessage) => interface State { loading: boolean; loaded: boolean; - authenticated: boolean; + authenticated?: boolean; error?: any; } @@ -76,7 +76,10 @@ const useAuth = (authParams = emptyParams, logoutOnFailure = true) => { setState({ loading: false, loaded: true, authenticated: true }); return; } - authProvider(AUTH_CHECK, authParams) + authProvider(AUTH_CHECK, { + location: location ? location.pathname : undefined, + ...authParams, + }) .then(() => { setState({ loading: false, loaded: true, authenticated: true }); }) 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; From 4511d6443c71c7a9e4d18f95f3d05923d709a45a Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Thu, 27 Jun 2019 16:16:37 +0200 Subject: [PATCH 10/18] Update WithPermissions to use the new hooks --- packages/ra-core/src/auth/WithPermissions.tsx | 143 ++++-------------- 1 file changed, 33 insertions(+), 110 deletions(-) diff --git a/packages/ra-core/src/auth/WithPermissions.tsx b/packages/ra-core/src/auth/WithPermissions.tsx index e143bcacd6d..6d900435248 100644 --- a/packages/ra-core/src/auth/WithPermissions.tsx +++ b/packages/ra-core/src/auth/WithPermissions.tsx @@ -1,39 +1,31 @@ -import { Children, Component, ReactNode, ComponentType } from 'react'; -import { connect } from 'react-redux'; +import { + Children, + FunctionComponent, + ReactElement, + ComponentType, +} from 'react'; import { Location } from 'history'; -import { match as Match } from 'react-router'; -import { AUTH_GET_PERMISSIONS, UserCheck } from './types'; -import AuthContext from './AuthContext'; -import { userCheck as userCheckAction } from '../actions/authActions'; -import { isLoggedIn as getIsLoggedIn } from '../reducer'; import warning from '../util/warning'; +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; + location?: Location; render?: WithPermissionsChildren; staticContext?: object; } -interface EnhancedProps { - isLoggedIn: boolean; - userCheck: UserCheck; -} - const isEmptyChildren = children => Children.count(children) === 0; /** @@ -45,7 +37,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. * @@ -58,11 +49,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} />} * /> * } /> * ]; @@ -72,96 +62,29 @@ const isEmptyChildren = children => Children.count(children) === 0; * </Admin> * ); */ -export class WithPermissions extends Component<Props & EnhancedProps> { - static contextType = AuthContext; - 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 = this.context; - const { 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, + staticContext, + ...props +}) => { + warning( + render && children && !isEmptyChildren(children), + 'You should not use both <WithPermissions render> and <WithPermissions children>; <WithPermissions children> will be ignored' + ); + + useAuth(authParams); + const { permissions } = usePermissions(authParams); // render even though the AUTH_GET_PERMISSIONS // isn't finished (optimistic rendering) - render() { - const authProvider = this.context; - const { - 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 (render) { + return render({ permissions, ...props }); } -} -const mapStateToProps = state => ({ - isLoggedIn: getIsLoggedIn(state), -}); -const EnhancedWithPermissions = connect( - mapStateToProps, - { userCheck: userCheckAction } -)(WithPermissions); + if (children) { + return children({ permissions, ...props }); + } +}; -export default EnhancedWithPermissions as ComponentType<Props>; +export default WithPermissions as ComponentType<Props>; From 75e260449f3fb9fdc4e066876e28558be3cbd199 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Thu, 27 Jun 2019 16:40:03 +0200 Subject: [PATCH 11/18] Make useAuth take ab options object as second argument --- packages/ra-core/src/auth/useAuth.spec.tsx | 8 ++++-- packages/ra-core/src/auth/useAuth.ts | 32 ++++++++++++++++++---- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/packages/ra-core/src/auth/useAuth.spec.tsx b/packages/ra-core/src/auth/useAuth.spec.tsx index 776b7222d7a..8caf5fdfa37 100644 --- a/packages/ra-core/src/auth/useAuth.spec.tsx +++ b/packages/ra-core/src/auth/useAuth.spec.tsx @@ -8,8 +8,8 @@ import AuthContext from './AuthContext'; import { showNotification } from '../actions/notificationActions'; import renderWithRedux from '../util/renderWithRedux'; -const UseAuth = ({ children, authParams, logoutOnFailure }: any) => { - const res = useAuth(authParams, logoutOnFailure); +const UseAuth = ({ children, authParams, options }: any) => { + const res = useAuth(authParams, options); return children(res); }; @@ -69,7 +69,9 @@ describe('useAuth', () => { const authProvider = () => Promise.reject('not good'); const { queryByText } = renderWithRedux( <AuthContext.Provider value={authProvider}> - <UseAuth logoutOnFailure={false}>{stateInpector}</UseAuth> + <UseAuth options={{ logoutOnFailure: false }}> + {stateInpector} + </UseAuth> </AuthContext.Provider> ); await wait(); diff --git a/packages/ra-core/src/auth/useAuth.ts b/packages/ra-core/src/auth/useAuth.ts index 4c88f05ae31..35897b16b89 100644 --- a/packages/ra-core/src/auth/useAuth.ts +++ b/packages/ra-core/src/auth/useAuth.ts @@ -24,6 +24,13 @@ interface State { const emptyParams = {}; +interface Options { + logoutOnFailure: boolean; +} +const defaultOptions = { + logoutOnFailure: true, +}; + /** * Hook for restricting access to authenticated users * @@ -39,7 +46,8 @@ const emptyParams = {}; * Useful in custom page components that require authentication. * * @param {Object} authParams Any params you want to pass to the authProvider - * @param {boolean} logoutOnFailure Whether the user should be logged out if the authProvider fails to authenticatde them. True by default. + * @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 }. * @@ -52,7 +60,10 @@ const emptyParams = {}; * return <Foo />; * }} />, * <Route path="/bar" render={() => { - * const { authenticated } = useAuth({ myContext: 'foobar' }, false); + * const { authenticated } = useAuth( + * { myContext: 'foobar' }, + * { logoutOnFailure: false } + * ); * return authenticated ? <Bar /> : <BarNotAuthenticated />; * }} />, * ]; @@ -62,7 +73,10 @@ const emptyParams = {}; * </Admin> * ); */ -const useAuth = (authParams = emptyParams, logoutOnFailure = true) => { +const useAuth = ( + authParams: object = emptyParams, + options: Options = defaultOptions +) => { const [state, setState] = useSafeSetState<State>({ loading: true, loaded: false, @@ -90,7 +104,7 @@ const useAuth = (authParams = emptyParams, logoutOnFailure = true) => { authenticated: false, error, }); - if (logoutOnFailure) { + if (options.logoutOnFailure) { authProvider(AUTH_LOGOUT); dispatch( replace({ @@ -105,7 +119,15 @@ const useAuth = (authParams = emptyParams, logoutOnFailure = true) => { ); dispatch(showNotification(errorMessage, 'warning')); }); - }, [authParams, authProvider, dispatch, nextPathname, logoutOnFailure]); // eslint-disable-line react-hooks/exhaustive-deps + }, [ + authParams, + authProvider, + dispatch, + location, + nextPathname, + options.logoutOnFailure, + setState, + ]); // eslint-disable-line react-hooks/exhaustive-deps return state; }; From 696cd991c1bccdb5844b9c319d436e817554363e Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Thu, 27 Jun 2019 16:51:01 +0200 Subject: [PATCH 12/18] Fix unit tests --- packages/ra-core/src/RoutesWithLayout.spec.tsx | 5 ++++- packages/ra-core/src/auth/Authenticated.spec.tsx | 4 ++-- 2 files changed, 6 insertions(+), 3 deletions(-) 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/auth/Authenticated.spec.tsx b/packages/ra-core/src/auth/Authenticated.spec.tsx index e183fbfcd8f..b95a2c4ceb8 100644 --- a/packages/ra-core/src/auth/Authenticated.spec.tsx +++ b/packages/ra-core/src/auth/Authenticated.spec.tsx @@ -18,7 +18,7 @@ describe('<Authenticated>', () => { </Authenticated> </AuthContext.Provider> ); - expect(authProvider).toBeCalledWith('AUTH_CHECK', {}); + expect(authProvider).toBeCalledWith('AUTH_CHECK', { location: '/' }); }); it('should call authProvider on update', () => { const authProvider = jest.fn(() => Promise.resolve()); @@ -34,7 +34,7 @@ describe('<Authenticated>', () => { expect(authProvider).toBeCalledTimes(2); expect(authProvider.mock.calls[1]).toEqual([ 'AUTH_CHECK', - { foo: 'bar' }, + { foo: 'bar', location: '/' }, ]); }); it('should render its child by default', () => { From c8958bd7d3ca5d2486197eca2bc535fd96424a50 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Thu, 27 Jun 2019 17:34:00 +0200 Subject: [PATCH 13/18] Simplify Resource by passing a component --- packages/ra-core/src/Resource.tsx | 48 +++++++------------ packages/ra-core/src/auth/WithPermissions.tsx | 16 +++++-- packages/ra-core/src/types.ts | 8 ++-- 3 files changed, 34 insertions(+), 38 deletions(-) diff --git a/packages/ra-core/src/Resource.tsx b/packages/ra-core/src/Resource.tsx index 46c03644907..3452e50f70b 100644 --- a/packages/ra-core/src/Resource.tsx +++ b/packages/ra-core/src/Resource.tsx @@ -89,12 +89,8 @@ export class Resource extends Component<ResourceProps & ConnectedProps> { path={`${match.url}/create`} render={routeProps => ( <WithPermissions - render={props => - createElement(create, { - basePath, - ...props, - }) - } + component={create} + basePath={basePath} {...routeProps} {...resource} /> @@ -106,16 +102,12 @@ export class Resource extends Component<ResourceProps & ConnectedProps> { path={`${match.url}/: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} /> @@ -127,16 +119,12 @@ export class Resource extends Component<ResourceProps & ConnectedProps> { path={`${match.url}/: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} /> @@ -148,12 +136,8 @@ export class Resource extends Component<ResourceProps & ConnectedProps> { path={`${match.url}`} render={routeProps => ( <WithPermissions - render={props => - createElement(list, { - basePath, - ...props, - }) - } + component={list} + basePath={basePath} {...routeProps} {...resource} /> diff --git a/packages/ra-core/src/auth/WithPermissions.tsx b/packages/ra-core/src/auth/WithPermissions.tsx index 6d900435248..55a1e8df04a 100644 --- a/packages/ra-core/src/auth/WithPermissions.tsx +++ b/packages/ra-core/src/auth/WithPermissions.tsx @@ -3,6 +3,7 @@ import { FunctionComponent, ReactElement, ComponentType, + createElement, } from 'react'; import { Location } from 'history'; @@ -21,9 +22,11 @@ type WithPermissionsChildren = ( interface Props { authParams?: object; children?: WithPermissionsChildren; + component?: ComponentType<any>; location?: Location; render?: WithPermissionsChildren; staticContext?: object; + [key: string]: any; } const isEmptyChildren = children => Children.count(children) === 0; @@ -66,22 +69,29 @@ const WithPermissions: FunctionComponent<Props> = ({ authParams, children, render, + component, staticContext, ...props }) => { warning( - render && children && !isEmptyChildren(children), - 'You should not use both <WithPermissions render> and <WithPermissions children>; <WithPermissions children> will be ignored' + (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) + if (component) { + return createElement(component, { permissions, ...props }); + } + // @deprecated if (render) { return render({ permissions, ...props }); } - + // @deprecated if (children) { return children({ permissions, ...props }); } diff --git a/packages/ra-core/src/types.ts b/packages/ra-core/src/types.ts index 480a3b0c9f1..6e2baf5131a 100644 --- a/packages/ra-core/src/types.ts +++ b/packages/ra-core/src/types.ts @@ -116,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<{ From fe264641ed701f2a3b510ef53c79e75e3b37db85 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Thu, 27 Jun 2019 22:43:37 +0200 Subject: [PATCH 14/18] Rewrite Resource with hooks --- packages/ra-core/src/Resource.spec.tsx | 159 +++++++++++----------- packages/ra-core/src/Resource.tsx | 146 +++++++++----------- packages/ra-core/src/RoutesWithLayout.tsx | 2 +- 3 files changed, 142 insertions(+), 165 deletions(-) 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 3452e50f70b..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,27 +64,24 @@ 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 component={create} basePath={basePath} {...routeProps} - {...resource} + {...props} /> )} /> )} {show && ( <Route - path={`${match.url}/:id/show`} + path={`${basePath}/:id/show`} render={routeProps => ( <WithPermissions component={show} @@ -109,14 +91,14 @@ export class Resource extends Component<ResourceProps & ConnectedProps> { .id )} {...routeProps} - {...resource} + {...props} /> )} /> )} {edit && ( <Route - path={`${match.url}/:id`} + path={`${basePath}/:id`} render={routeProps => ( <WithPermissions component={edit} @@ -126,39 +108,37 @@ export class Resource extends Component<ResourceProps & ConnectedProps> { .id )} {...routeProps} - {...resource} + {...props} /> )} /> )} {list && ( <Route - path={`${match.url}`} + path={`${basePath}`} render={routeProps => ( <WithPermissions 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.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)} /> )} /> From 7a3327923ab8240ef204b60dadeb2b5dfaced430 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Thu, 27 Jun 2019 23:43:00 +0200 Subject: [PATCH 15/18] Update Authentication doc page --- docs/Authentication.md | 243 +++++++++++++++++++++++-------------- docs/_layouts/default.html | 12 +- 2 files changed, 157 insertions(+), 98 deletions(-) diff --git a/docs/Authentication.md b/docs/Authentication.md index af6bc4ec886..82550144a7d 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 `<Admin>` 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 = () => ( + <Admin authProvider={authProvider}> + ... + </Admin> +); +``` + +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 `<Admin>` 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 `<Admin>` component: - -```jsx -// in src/App.js -import authProvider from './authProvider'; -const App = () => ( - <Admin authProvider={authProvider}> - ... - </Admin> -); +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 `<Admin>`, 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 `<Admin>`, 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 ( - <ThemeProvider theme={this.props.theme}> - <form onSubmit={this.submit}> - ... - </form> - </ThemeProvider> - ); - } + return ( + <ThemeProvider theme={theme}> + <form onSubmit={submit}> + <input name="email" type="email" value={email} onChange={e => setEmail(e.target.value)} /> + <input name="password" type="password" value={password} onChange={e => setPassword(e.target.value)} /> + </form> + </ThemeProvider> + ); }; -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 }) => ( - <Responsive - xsmall={ - <MenuItem - onClick={userLogout} - {...rest} - > - <ExitIcon /> Logout - </MenuItem> - } - medium={ - <Button - onClick={userLogout} - size="small" - {...rest} - > - <ExitIcon /> Logout - </Button> - } - /> -); -export default connect(undefined, { userLogout })(MyLogoutButton); +const MyLogoutButton = props => { + const dispatch = useDispatch(); + return ( + <Responsive + xsmall={ + <MenuItem + onClick={() => dispatch(userLogout())} + {...props} + > + <ExitIcon /> Logout + </MenuItem> + } + medium={ + <Button + onClick={() => dispatch(userLogout())} + size="small" + {...props} + > + <ExitIcon /> Logout + </Button> + } + /> + ); +}; +export default MyLogoutButton; // in src/App.js import MyLoginPage from './MyLoginPage'; @@ -316,40 +338,75 @@ const App = () => ( ); ``` +**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 +// ... + <MenuItem +- onClick={() => dispatch(userLogout())} ++ onClick={() => dispatch(userLogout('/custom-login'))} + {...props} + > + <ExitIcon /> Logout + </MenuItem> +``` + ## Restricting Access To A Custom Page -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 `<Authenticated>` 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 }) => ( - <Authenticated authParams={{ foo: 'bar' }} location={location}> +const MyPage = () => { + useAuth(); // redirects to login if not authenticated + return ( <div> ... </div> - </Authenticated> -); + ) +}; -export default withRouter(MyPage); +export default MyPage; ``` -{% endraw %} -The `<Authenticated>` 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, `<Authenticated>` 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 ( + <div> + ... + </div> + ) +}; +``` -## 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 ? ( + <div> + ... + </div> + ) : 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 <AnonymousContent />; + return <AuthenticatedContent /> +}; ``` diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index b17fb411aae..44941bd4297 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="#restricting-access-to-a-custom-page">Restricting Access To A Page</a> </li> </ul> </li> From eff91ca8add59900ed397a07b95abfd42ef10608 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Fri, 28 Jun 2019 00:20:20 +0200 Subject: [PATCH 16/18] Update Authorization doc --- docs/Authentication.md | 2 +- docs/Authorization.md | 176 ++++++++++++++----------------------- docs/_layouts/default.html | 15 ++-- 3 files changed, 78 insertions(+), 115 deletions(-) diff --git a/docs/Authentication.md b/docs/Authentication.md index 82550144a7d..5523486babc 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -352,7 +352,7 @@ const App = () => ( </MenuItem> ``` -## Restricting Access To A Custom Page +## `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 `useAuth()` hook, which calls the `authProvider` with the `AUTH_CHECK` type on mount, and redirects to login if it returns a rejected Promise. diff --git a/docs/Authorization.md b/docs/Authorization.md index 4a08e4f00e1..d592b73ed50 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 `<Admin>` only child. React-admin will call this function with the permissions returned by the `authProvider`. -{% raw %} ```jsx <Admin dataProvider={dataProvider} @@ -87,42 +86,22 @@ It's possible to restrict access to resources or their views inside the `Admin` ]} </Admin> ``` -{% 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 }) => - <Toolbar {...props}> - <SaveButton - label="user.action.save_and_show" - redirect="show" - submitOnEnter={true} - /> - {permissions === 'admin' && - <SaveButton - label="user.action.save_and_add" - redirect={false} - submitOnEnter={false} - variant="text" - />} - </Toolbar>; - export const UserCreate = ({ permissions, ...props }) => <Create {...props}> <SimpleForm - toolbar={<UserCreateToolbar permissions={permissions} {...props} />} defaultValue={{ role: 'user' }} > <TextInput source="name" validate={[required()]} /> @@ -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 }) => <Filter {...props}> @@ -167,44 +143,30 @@ const UserFilter = ({ permissions, ...props }) => alwaysOn /> <TextInput source="name" /> - {permissions === 'admin' ? <TextInput source="role" /> : null} + {permissions === 'admin' && <TextInput source="role" />} </Filter>; export const UserList = ({ permissions, ...props }) => <List {...props} filters={props => <UserFilter permissions={permissions} {...props} />} - sort={{ field: 'name', order: 'ASC' }} > - <Responsive - small={ - <SimpleList - primaryText={record => record.name} - secondaryText={record => - permissions === 'admin' ? record.role : null} - /> - } - medium={ - <Datagrid> - <TextField source="id" /> - <TextField source="name" /> - {permissions === 'admin' && <TextField source="role" />} - {permissions === 'admin' && <EditButton />} - <ShowButton /> - </Datagrid> - } - /> + <Datagrid> + <TextField source="id" /> + <TextField source="name" /> + {permissions === 'admin' && <TextField source="role" />} + {permissions === 'admin' && <EditButton />} + <ShowButton /> + </Datagrid> </List>; ``` -{% 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 }) => ( </Card> ); ``` -{% 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_GT_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 }) => ( - <Card> - <Title title="My custom page" /> - <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 44941bd4297..a1640f44303 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -650,7 +650,7 @@ Components</a> </li> <li class="chapter"> - <a href="#restricting-access-to-a-custom-page">Restricting Access To A Page</a> + <a href="#useauth-hook"><code>useAuth()</code> Hook</a> </li> </ul> </li> @@ -663,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> From 66025516a61a0ecaf5a6b65d2fdc52d2b93ef1c2 Mon Sep 17 00:00:00 2001 From: fzaninotto <fzaninotto@gmail.com> Date: Fri, 28 Jun 2019 09:15:32 +0200 Subject: [PATCH 17/18] Add Ugrade guide --- UPGRADE.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) 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) => ( </Create> ); ``` + +## `authProvider` No Longer Uses Legacy React Context + +When you provide an `authProvider` to the `<Admin>` 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 <div>I'm authenticated</div>; +} + +-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; + // ... +} +``` From a54d5e3293ebda06987bf2e7d0470f92fc6108bb Mon Sep 17 00:00:00 2001 From: Francois Zaninotto <fzaninotto@gmail.com> Date: Mon, 1 Jul 2019 09:26:30 +0200 Subject: [PATCH 18/18] Update docs/Authorization.md Co-Authored-By: Gildas Garcia <gildas.garcia@pm.me> --- docs/Authorization.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Authorization.md b/docs/Authorization.md index d592b73ed50..c6db4d4ff81 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -188,7 +188,7 @@ export default ({ permissions }) => ( ## `usePermissions()` Hook -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_GT_PERMISSIONS` type on mount, and returns the result when available: +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: ```jsx // in src/MyPage.js