From 878437dab3ba6e44c7a2a0820cd6bc744198083d Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Thu, 5 Sep 2019 23:51:42 +0200 Subject: [PATCH 01/26] Reimplement auth logic using hooks --- examples/simple/src/customRouteLayout.js | 4 +- .../ra-core/src/auth/Authenticated.spec.tsx | 61 ++--- packages/ra-core/src/auth/Authenticated.tsx | 10 +- packages/ra-core/src/auth/WithPermissions.tsx | 4 +- packages/ra-core/src/auth/index.ts | 14 +- packages/ra-core/src/auth/useAuth.ts | 134 ---------- packages/ra-core/src/auth/useAuthProvider.ts | 237 ++++++++++++++++++ ...useAuth.spec.tsx => useAuthState.spec.tsx} | 37 +-- packages/ra-core/src/auth/useAuthState.ts | 70 ++++++ .../src/auth/useAuthenticated.spec.tsx | 90 +++++++ .../ra-core/src/auth/useAuthenticated.tsx | 34 +++ packages/ra-core/src/auth/usePermissions.ts | 19 +- packages/ra-core/src/util/index.ts | 2 + .../ra-ui-materialui/src/auth/LoginForm.tsx | 34 ++- packages/ra-ui-materialui/src/auth/Logout.tsx | 11 +- 15 files changed, 530 insertions(+), 231 deletions(-) delete mode 100644 packages/ra-core/src/auth/useAuth.ts create mode 100644 packages/ra-core/src/auth/useAuthProvider.ts rename packages/ra-core/src/auth/{useAuth.spec.tsx => useAuthState.spec.tsx} (53%) create mode 100644 packages/ra-core/src/auth/useAuthState.ts create mode 100644 packages/ra-core/src/auth/useAuthenticated.spec.tsx create mode 100644 packages/ra-core/src/auth/useAuthenticated.tsx diff --git a/examples/simple/src/customRouteLayout.js b/examples/simple/src/customRouteLayout.js index 0d94bd0f02b..c0102b78f95 100644 --- a/examples/simple/src/customRouteLayout.js +++ b/examples/simple/src/customRouteLayout.js @@ -1,8 +1,8 @@ import React from 'react'; -import { useGetList, useAuth, Title } from 'react-admin'; +import { useGetList, useAuthenticated, Title } from 'react-admin'; const CustomRouteLayout = () => { - useAuth(); + useAuthenticated(); const { total, loaded } = useGetList( 'posts', { page: 1, perPage: 10 }, diff --git a/packages/ra-core/src/auth/Authenticated.spec.tsx b/packages/ra-core/src/auth/Authenticated.spec.tsx index 7c41e79eb15..af5a3059f4d 100644 --- a/packages/ra-core/src/auth/Authenticated.spec.tsx +++ b/packages/ra-core/src/auth/Authenticated.spec.tsx @@ -1,48 +1,53 @@ import React from 'react'; import expect from 'expect'; -import { cleanup } from '@testing-library/react'; +import { cleanup, wait } from '@testing-library/react'; +import { push } from 'connected-react-router'; import Authenticated from './Authenticated'; import AuthContext from './AuthContext'; import renderWithRedux from '../util/renderWithRedux'; +import { showNotification } from '../actions/notificationActions'; describe('', () => { afterEach(cleanup); + const Foo = () =>
Foo
; - it('should call authProvider on mount', () => { - const authProvider = jest.fn(() => Promise.resolve()); - renderWithRedux( - - - - - + + it('should render its child by default', async () => { + const { dispatch, queryByText } = renderWithRedux( + + + ); - expect(authProvider).toBeCalledWith('AUTH_CHECK', { location: '/' }); + expect(queryByText('Foo')).toBeDefined(); + await wait(); + expect(dispatch).toHaveBeenCalledTimes(0); }); - it('should call authProvider on update', () => { - const authProvider = jest.fn(() => Promise.resolve()); - const FooWrapper = props => ( + + 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( - + ); - const { rerender } = renderWithRedux(); - rerender(); - expect(authProvider).toBeCalledTimes(2); - expect(authProvider.mock.calls[1]).toEqual([ - 'AUTH_CHECK', - { foo: 'bar', location: '/' }, - ]); - }); - it('should render its child by default', () => { - const { queryByText } = renderWithRedux( - - - + await wait(); + expect(authProvider.mock.calls[0][0]).toBe('AUTH_CHECK'); + expect(authProvider.mock.calls[1][0]).toBe('AUTH_LOGOUT'); + expect(dispatch).toHaveBeenCalledTimes(3); + expect(dispatch.mock.calls[0][0]).toEqual( + showNotification('ra.auth.auth_check_error', 'warning', { + messageArgs: {}, + undoable: false, + }) ); - expect(queryByText('Foo')).toBeDefined(); + expect(dispatch.mock.calls[1][0]).toEqual( + push({ pathname: '/login', state: { nextPathname: '/' } }) + ); + expect(dispatch.mock.calls[2][0]).toEqual({ type: 'RA/CLEAR_STATE' }); }); }); diff --git a/packages/ra-core/src/auth/Authenticated.tsx b/packages/ra-core/src/auth/Authenticated.tsx index 935b5f368f0..68c99c03dba 100644 --- a/packages/ra-core/src/auth/Authenticated.tsx +++ b/packages/ra-core/src/auth/Authenticated.tsx @@ -1,6 +1,6 @@ import { cloneElement, ReactElement, FunctionComponent } from 'react'; -import useAuth from './useAuth'; +import useAuthenticated from './useAuthenticated'; interface Props { children: ReactElement; @@ -9,15 +9,17 @@ interface Props { } /** - * Restrict access to children to authenticated users + * Restrict access to children to authenticated users. + * Redirects anonymous users to the login page. * - * Useful for Route components ; used internally by Resource. * Use it to decorate your custom page components to require * authentication. * * You can set additional `authParams` at will if your authProvider * requires it. * + * @see useAuthenticated + * * @example * import { Authenticated } from 'react-admin'; * @@ -40,7 +42,7 @@ const Authenticated: FunctionComponent = ({ location, // kept for backwards compatibility, unused ...rest }) => { - useAuth(authParams); + useAuthenticated(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/WithPermissions.tsx b/packages/ra-core/src/auth/WithPermissions.tsx index 55a1e8df04a..ea06a665873 100644 --- a/packages/ra-core/src/auth/WithPermissions.tsx +++ b/packages/ra-core/src/auth/WithPermissions.tsx @@ -8,7 +8,7 @@ import { import { Location } from 'history'; import warning from '../util/warning'; -import useAuth from './useAuth'; +import useAuthenticated from './useAuthenticated'; import usePermissions from './usePermissions'; export interface WithPermissionsChildrenParams { @@ -80,7 +80,7 @@ const WithPermissions: FunctionComponent = ({ 'You should only use one of the `component`, `render` and `children` props in ' ); - useAuth(authParams); + useAuthenticated(authParams); const { permissions } = usePermissions(authParams); // render even though the AUTH_GET_PERMISSIONS // isn't finished (optimistic rendering) diff --git a/packages/ra-core/src/auth/index.ts b/packages/ra-core/src/auth/index.ts index 74de6a38a32..0cba9fe80b3 100644 --- a/packages/ra-core/src/auth/index.ts +++ b/packages/ra-core/src/auth/index.ts @@ -1,7 +1,17 @@ import Authenticated from './Authenticated'; import AuthContext from './AuthContext'; -import useAuth from './useAuth'; +import useAuthProvider from './useAuthProvider'; +import useAuthState from './useAuthState'; import usePermissions from './usePermissions'; +import useAuthenticated from './useAuthenticated'; import WithPermissions from './WithPermissions'; export * from './types'; -export { AuthContext, Authenticated, WithPermissions, useAuth, usePermissions }; +export { + AuthContext, + Authenticated, + WithPermissions, + useAuthProvider, + useAuthState, + useAuthenticated, + usePermissions, +}; diff --git a/packages/ra-core/src/auth/useAuth.ts b/packages/ra-core/src/auth/useAuth.ts deleted file mode 100644 index 35897b16b89..00000000000 --- a/packages/ra-core/src/auth/useAuth.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { useEffect, useContext } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { replace } from 'connected-react-router'; - -import AuthContext from './AuthContext'; -import { AUTH_CHECK, AUTH_LOGOUT } from './types'; -import { useSafeSetState } from '../util/hooks'; -import { showNotification } from '../actions/notificationActions'; -import { ReduxState } from '../types'; - -const getErrorMessage = (error, defaultMessage) => - typeof error === 'string' - ? error - : typeof error === 'undefined' || !error.message - ? defaultMessage - : error.message; - -interface State { - loading: boolean; - loaded: boolean; - authenticated?: boolean; - error?: any; -} - -const emptyParams = {}; - -interface Options { - logoutOnFailure: boolean; -} -const defaultOptions = { - logoutOnFailure: true, -}; - -/** - * Hook for restricting access to authenticated users - * - * Calls the authProvider asynchronously with the AUTH_CHECK verb. - * If the authProvider returns a rejected promise, logs the user out. - * - * The return value updates according to the request state: - * - * - start: { authenticated: false, loading: true, loaded: false } - * - success: { authenticated: true, loading: false, loaded: true } - * - error: { error: [error from provider], authenticated: false, loading: false, loaded: true } - * - * Useful in custom page components that require authentication. - * - * @param {Object} authParams Any params you want to pass to the authProvider - * @param {Object} options - * @param {boolean} options.logoutOnFailure Whether the user should be logged out if the authProvider fails to authenticatde them. True by default. - * - * @returns The current auth check state. Destructure as { authenticated, error, loading, loaded }. - * - * @example - * import { useAuth } from 'react-admin'; - * - * const CustomRoutes = [ - * { - * useAuth(); - * return ; - * }} />, - * { - * const { authenticated } = useAuth( - * { myContext: 'foobar' }, - * { logoutOnFailure: false } - * ); - * return authenticated ? : ; - * }} />, - * ]; - * const App = () => ( - * - * ... - * - * ); - */ -const useAuth = ( - authParams: object = emptyParams, - options: Options = defaultOptions -) => { - const [state, setState] = useSafeSetState({ - loading: true, - loaded: false, - }); - const location = useSelector((state: ReduxState) => state.router.location); - const nextPathname = location && location.pathname; - const authProvider = useContext(AuthContext); - const dispatch = useDispatch(); - useEffect(() => { - if (!authProvider) { - setState({ loading: false, loaded: true, authenticated: true }); - return; - } - authProvider(AUTH_CHECK, { - location: location ? location.pathname : undefined, - ...authParams, - }) - .then(() => { - setState({ loading: false, loaded: true, authenticated: true }); - }) - .catch(error => { - setState({ - loading: false, - loaded: true, - authenticated: false, - error, - }); - if (options.logoutOnFailure) { - authProvider(AUTH_LOGOUT); - dispatch( - replace({ - pathname: (error && error.redirectTo) || '/login', - state: { nextPathname }, - }) - ); - } - const errorMessage = getErrorMessage( - error, - 'ra.auth.auth_check_error' - ); - dispatch(showNotification(errorMessage, 'warning')); - }); - }, [ - authParams, - authProvider, - dispatch, - location, - nextPathname, - options.logoutOnFailure, - setState, - ]); // eslint-disable-line react-hooks/exhaustive-deps - return state; -}; - -export default useAuth; diff --git a/packages/ra-core/src/auth/useAuthProvider.ts b/packages/ra-core/src/auth/useAuthProvider.ts new file mode 100644 index 00000000000..2a2f3d913d6 --- /dev/null +++ b/packages/ra-core/src/auth/useAuthProvider.ts @@ -0,0 +1,237 @@ +import { useCallback, useMemo, useContext } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { push } from 'connected-react-router'; +import { Location } from 'history'; + +import AuthContext from './AuthContext'; +import { + AUTH_LOGIN, + AUTH_CHECK, + AUTH_LOGOUT, + AUTH_GET_PERMISSIONS, +} from './types'; +import { clearState } from '../actions/clearActions'; +import { ReduxState } from '../types'; +import useNotify from '../sideEffect/useNotify'; + +const defaultParams = { + loginUrl: '/login', + afterLoginUrl: '/', +}; + +const emptyArray = []; + +interface AuthCallbacks { + login: (params: any, pathName?: string) => Promise; + logout: (redirectTo: string) => Promise; + check: ( + location?: Location, + logoutOnFailure?: boolean, + redirectTo?: string + ) => Promise; + getPermissions: (location?: Location) => Promise; +} + +/** + * Hook for calling the authProvider. + * + * Returns a set of callbacks for each of the authProvider verbs. + * These callbacks return a Promise for the authProvider response. + * + * - login: calls the AUTH_LOGIN verb and redirects to the previous + * authenticated page (or the home page) on success. + * + * - logout: calls the AUTH_LOGOUT verb, redirects to the login page, + * and clears the Redux state. + * + * - check: calls the AUTH_CHECK verb. In case of rejection, + * redirects to the login page, displays a notification, and throws an error. + * + * - getPermissions: calls the AUTH_GET_PERMISSIONS verb. + * + * This is a low level hook. See those more specialized hooks + * for common authentication tasks, based on useAuthProvider. + * + * @see useAuthenticated + * @see useAuthState + * @see usePermissions + * + * @param {Object} authParams Any params you want to pass to the authProvider + * + * @returns {Object} { login, logout, checkAuth, getPermissions } callbacks for the authProvider + * + * @example + * + * import { useAuthProvider } from 'react-admin'; + * + * const LoginButton = () => { + * const [loading, setLoading] = useState(false); + * const { login } = useAuthProvider(); + * const handleClick = { + * setLoading(true); + * login({ username: 'john', password: 'p@ssw0rd' }, '/posts') + * .then(() => setLoading(false)); + * } + * return ; + * } + * + * const LogoutButton = () => { + * const { logout } = useAuthProvider(); + * const handleClick = () => logout(); + * return ; + * } + * + * const MyProtectedPage = () => { + * const { check } = useAuthProvider(); + * useEffect(() => { + * check().catch(() => {}); + * }, []); + * return

Private content: EZAEZEZAET

+ * } // tip: use useAuthenticated() hook instead + * + * const MyPage = () => { + * const { check } = useAuthProvider(); + * const [authenticated, setAuthenticated] = useState(true); // optimistic auth + * useEffect(() => { + * check(undefined, false) + * .then() => setAuthenticated(true)) + * .catch(() => setAuthenticated(false)); + * }, []); + * return authenticated ? : ; + * } // tip: use useAuthState() hook instead + */ +const useAuthProvider = (authParams: any = defaultParams): AuthCallbacks => { + const authProvider = useContext(AuthContext); + const currentLocation = useSelector( + (state: ReduxState) => state.router.location + ); + const nextPathName = + currentLocation.state && currentLocation.state.nextPathname; + const dispatch = useDispatch(); + const notify = useNotify(); + + /** + * Log a user in by calling the authProvider AUTH_LOGIN verb + * + * @param {object} params Login parameters to pass to the authProvider. May contain username/email, password, etc + * @param {string} pathName The path to redirect to after login. By default, redirects to the home page, or to the last page visited after deconnexion. + * + * @return {Promise} The authProvider response + */ + const login = useCallback( + (params, pathName = authParams.afterLoginUrl) => + authProvider(AUTH_LOGIN, { ...params, ...authParams }).then(ret => { + dispatch(push(nextPathName || pathName)); + return ret; + }), + [authParams, authProvider, dispatch, nextPathName] + ); + + /** + * Log the current user out by calling the authProvider AUTH_LOGOUT verb, + * and redirect them to the login screen. + * + * @param {string} redirectTo The path name to redirect the user to (optional, defaults to login) + * + * @return {Promise} The authProvider response + */ + const logout = useCallback( + (redirectTo = authParams.loginUrl) => + authProvider(AUTH_LOGOUT, authParams).then( + redirectToFromProvider => { + dispatch( + push({ + pathname: redirectToFromProvider || redirectTo, + state: { + nextPathname: + currentLocation && currentLocation.pathname, + }, + }) + ); + dispatch(clearState()); + return redirectToFromProvider; + } + ), + [authParams, authProvider, currentLocation, dispatch] + ); + + /** + * Check if the current user is authenticated by calling the authProvider AUTH_CHECK verb. + * Logs the user out on failure. + * + * @param {Location} location The path name to check auth for + * @param {boolean} logoutOnFailure Whether the user should be logged out if the authProvider fails to authenticatde them. True by default. + * + * @return {Promise} Resolved to the authProvider response if the user passes the check, or rejected with an error otherwise + */ + const check = useCallback( + ( + location: Location = currentLocation, + logoutOnFailure = true, + redirectTo = authParams.loginUrl + ) => + authProvider(AUTH_CHECK, { + location, + ...authParams, + }).catch(error => { + if (logoutOnFailure) { + logout(redirectTo); + notify( + getErrorMessage(error, 'ra.auth.auth_check_error'), + 'warning' + ); + } + throw error; + }), + [authParams, authProvider, currentLocation, logout, notify] + ); + + const getPermissions = useCallback( + (location: Location = currentLocation) => + authProvider(AUTH_GET_PERMISSIONS, { location, ...authParams }), + [authParams, authProvider, currentLocation] + ); + + const callbacksForMissingAuthProvider = useMemo( + () => ({ + login: (_, __) => { + dispatch(push(authParams.afterLoginUrl)); + return Promise.resolve(); + }, + logout: _ => { + dispatch( + push({ + pathname: authParams.loginUrl, + state: { + nextPathname: + currentLocation && currentLocation.pathname, + }, + }) + ); + dispatch(clearState()); + return Promise.resolve(); + }, + check: () => Promise.resolve(true), + getPermissions: () => Promise.resolve(emptyArray), + }), + [ + authParams.afterLoginUrl, + authParams.loginUrl, + currentLocation, + dispatch, + ] + ); + + return authProvider + ? { login, logout, check, getPermissions } + : callbacksForMissingAuthProvider; +}; + +const getErrorMessage = (error, defaultMessage) => + typeof error === 'string' + ? error + : typeof error === 'undefined' || !error.message + ? defaultMessage + : error.message; + +export default useAuthProvider; diff --git a/packages/ra-core/src/auth/useAuth.spec.tsx b/packages/ra-core/src/auth/useAuthState.spec.tsx similarity index 53% rename from packages/ra-core/src/auth/useAuth.spec.tsx rename to packages/ra-core/src/auth/useAuthState.spec.tsx index edd8c245e9a..e9147450b05 100644 --- a/packages/ra-core/src/auth/useAuth.spec.tsx +++ b/packages/ra-core/src/auth/useAuthState.spec.tsx @@ -1,15 +1,13 @@ import React from 'react'; import expect from 'expect'; import { cleanup, wait } from '@testing-library/react'; -import { replace } from 'connected-react-router'; -import useAuth from './useAuth'; +import useAuthState from './useAuthState'; import AuthContext from './AuthContext'; -import { showNotification } from '../actions/notificationActions'; import renderWithRedux from '../util/renderWithRedux'; -const UseAuth = ({ children, authParams, options }: any) => { - const res = useAuth(authParams, options); +const UseAuth = ({ children, authParams }: any) => { + const res = useAuthState(authParams); return children(res); }; @@ -18,11 +16,10 @@ const stateInpector = state => ( {state.loading && 'LOADING'} {state.loaded && 'LOADED'} {state.authenticated && 'AUTHENTICATED'} - {state.error && 'ERROR'} ); -describe('useAuth', () => { +describe('useAuthState', () => { afterEach(cleanup); it('should return a loading state on mount', () => { @@ -31,7 +28,7 @@ describe('useAuth', () => { ); expect(queryByText('LOADING')).not.toBeNull(); expect(queryByText('LOADED')).toBeNull(); - expect(queryByText('AUTHENTICATED')).toBeNull(); + expect(queryByText('AUTHENTICATED')).not.toBeNull(); }); it('should return authenticated by default after a tick', async () => { @@ -44,28 +41,7 @@ describe('useAuth', () => { 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( - - {stateInpector} - - ); - 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 () => { + it('should return an error after a tick if the auth fails', async () => { const authProvider = () => Promise.reject('not good'); const { queryByText } = renderWithRedux( @@ -78,6 +54,5 @@ describe('useAuth', () => { 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/useAuthState.ts b/packages/ra-core/src/auth/useAuthState.ts new file mode 100644 index 00000000000..8c26da071e4 --- /dev/null +++ b/packages/ra-core/src/auth/useAuthState.ts @@ -0,0 +1,70 @@ +import { useEffect } from 'react'; + +import useAuthProvider from './useAuthProvider'; +import { useSafeSetState } from '../util/hooks'; + +interface State { + loading: boolean; + loaded: boolean; + authenticated?: boolean; +} + +const emptyParams = {}; + +/** + * Hook for getting the authentication status and restricting access to authenticated users + * + * Calls the authProvider asynchronously with the AUTH_CHECK verb. + * If the authProvider returns a rejected promise, logs the user out. + * + * The return value updates according to the authProvider request state: + * + * - start: { authenticated: false, loading: true, loaded: false } + * - success: { authenticated: true, loading: false, loaded: true } + * - error: { authenticated: false, loading: false, loaded: true } + * + * Useful in custom page components that can work both for connected and + * anonymous users. For pages that can only work for connected users, + * prefer the useAuthenticated() hook. + * + * @see useAuthenticated() + * + * @param {Object} authParams Any params you want to pass to the authProvider + * + * @returns The current auth check state. Destructure as { authenticated, error, loading, loaded }. + * + * @example + * import { useAuthState } from 'react-admin'; + * + * const CustomRoutes = [ + * { + * const { authenticated } = useAuthState({ myContext: 'foobar' }); + * return authenticated ? : ; + * }} />, + * ]; + * const App = () => ( + * + * ... + * + * ); + */ +const useAuthState = (authParams: any = emptyParams): State => { + const [state, setState] = useSafeSetState({ + loading: true, + loaded: false, + authenticated: true, // optimistic + }); + const { check } = useAuthProvider(authParams); + useEffect(() => { + check(undefined, false) + .then(() => + setState({ loading: false, loaded: true, authenticated: true }) + ) + .catch(() => + setState({ loading: false, loaded: true, authenticated: false }) + ); + }, [check, setState]); + return state; +}; + +export default useAuthState; diff --git a/packages/ra-core/src/auth/useAuthenticated.spec.tsx b/packages/ra-core/src/auth/useAuthenticated.spec.tsx new file mode 100644 index 00000000000..a3e760038e9 --- /dev/null +++ b/packages/ra-core/src/auth/useAuthenticated.spec.tsx @@ -0,0 +1,90 @@ +import React from 'react'; +import expect from 'expect'; +import { cleanup, wait } from '@testing-library/react'; +import { push } from 'connected-react-router'; + +import Authenticated from './Authenticated'; +import AuthContext from './AuthContext'; +import renderWithRedux from '../util/renderWithRedux'; +import { showNotification } from '../actions/notificationActions'; + +describe('useAuthenticated', () => { + afterEach(cleanup); + + const Foo = () =>
Foo
; + + it('should call authProvider on mount', () => { + const authProvider = jest.fn(() => Promise.resolve()); + const { dispatch } = renderWithRedux( + + + + + + ); + expect(authProvider).toBeCalledTimes(1); + expect(authProvider.mock.calls[0][0]).toBe('AUTH_CHECK'); + const payload = authProvider.mock.calls[0][1] as any; + expect(payload.afterLoginUrl).toBe('/'); + expect(payload.loginUrl).toBe('/login'); + expect(payload.location.pathname).toBe('/'); + expect(dispatch).toHaveBeenCalledTimes(0); + }); + + it('should call authProvider on update', () => { + const authProvider = jest.fn(() => Promise.resolve()); + const FooWrapper = props => ( + + + + + + ); + const { rerender, dispatch } = renderWithRedux(); + rerender(); + expect(authProvider).toBeCalledTimes(2); + expect(authProvider.mock.calls[1][0]).toBe('AUTH_CHECK'); + const payload = authProvider.mock.calls[1][1] as any; + expect(payload.foo).toBe('bar'); + expect(payload.location.pathname).toBe('/'); + expect(dispatch).toHaveBeenCalledTimes(0); + }); + + it('should not block rendering by default', async () => { + const { dispatch, queryByText } = renderWithRedux( + + + + ); + expect(queryByText('Foo')).toBeDefined(); + await wait(); + expect(dispatch).toHaveBeenCalledTimes(0); + }); + + 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( + + + + + + ); + await wait(); + expect(authProvider.mock.calls[0][0]).toBe('AUTH_CHECK'); + expect(authProvider.mock.calls[1][0]).toBe('AUTH_LOGOUT'); + expect(dispatch).toHaveBeenCalledTimes(3); + expect(dispatch.mock.calls[0][0]).toEqual( + showNotification('ra.auth.auth_check_error', 'warning', { + messageArgs: {}, + undoable: false, + }) + ); + expect(dispatch.mock.calls[1][0]).toEqual( + push({ pathname: '/login', state: { nextPathname: '/' } }) + ); + expect(dispatch.mock.calls[2][0]).toEqual({ type: 'RA/CLEAR_STATE' }); + }); +}); diff --git a/packages/ra-core/src/auth/useAuthenticated.tsx b/packages/ra-core/src/auth/useAuthenticated.tsx new file mode 100644 index 00000000000..33776ec006d --- /dev/null +++ b/packages/ra-core/src/auth/useAuthenticated.tsx @@ -0,0 +1,34 @@ +import { useEffect } from 'react'; +import useAuthProvider from './useAuthProvider'; + +/** + * Restrict access to children to authenticated users. + * Redirects anonymous users to the login page. + * + * Use it in your custom page components to require + * authentication. + * + * You can set additional `authParams` at will if your authProvider + * requires it. + * + * @example + * import { useAuthenticated } from 'react-admin'; + * const FooPage = () => { + * useAuthenticated(); + * return ; + * } + * const CustomRoutes = [ + * } /> + * ]; + * const App = () => ( + * + * ... + * + * ); + */ +export default authParams => { + const { check } = useAuthProvider(authParams); + useEffect(() => { + check().catch(() => {}); + }, [check]); +}; diff --git a/packages/ra-core/src/auth/usePermissions.ts b/packages/ra-core/src/auth/usePermissions.ts index 07382b93397..874687a5808 100644 --- a/packages/ra-core/src/auth/usePermissions.ts +++ b/packages/ra-core/src/auth/usePermissions.ts @@ -1,8 +1,7 @@ -import { useEffect, useContext } from 'react'; +import { useEffect } from 'react'; import { useSelector } from 'react-redux'; -import AuthContext from './AuthContext'; -import { AUTH_GET_PERMISSIONS } from './types'; +import useAuthProvider from './useAuthProvider'; import { useSafeSetState } from '../util/hooks'; import { ReduxState } from '../types'; @@ -51,17 +50,9 @@ const usePermissions = (authParams = emptyParams) => { loaded: false, }); const location = useSelector((state: ReduxState) => state.router.location); - const pathname = location && location.pathname; - const authProvider = useContext(AuthContext); + const { getPermissions } = useAuthProvider(authParams); useEffect(() => { - if (!authProvider) { - setState({ loading: false, loaded: true }); - return; - } - authProvider(AUTH_GET_PERMISSIONS, { - location: pathname, - ...authParams, - }) + getPermissions(location) .then(permissions => { setState({ loading: false, loaded: true, permissions }); }) @@ -72,7 +63,7 @@ const usePermissions = (authParams = emptyParams) => { error, }); }); - }, [authParams, authProvider, pathname]); // eslint-disable-line react-hooks/exhaustive-deps + }, [authParams, getPermissions, location, setState]); return state; }; diff --git a/packages/ra-core/src/util/index.ts b/packages/ra-core/src/util/index.ts index 7f11314d11e..27f5c5cadfc 100644 --- a/packages/ra-core/src/util/index.ts +++ b/packages/ra-core/src/util/index.ts @@ -11,6 +11,7 @@ import TestContext from './TestContext'; import renderWithRedux from './renderWithRedux'; import warning from './warning'; import useWhyDidYouUpdate from './useWhyDidYouUpdate'; +import { useSafeSetState } from './hooks'; export { downloadCSV, @@ -26,4 +27,5 @@ export { renderWithRedux, warning, useWhyDidYouUpdate, + useSafeSetState, }; diff --git a/packages/ra-ui-materialui/src/auth/LoginForm.tsx b/packages/ra-ui-materialui/src/auth/LoginForm.tsx index 4fa37b47cae..4e2c84cc677 100644 --- a/packages/ra-ui-materialui/src/auth/LoginForm.tsx +++ b/packages/ra-ui-materialui/src/auth/LoginForm.tsx @@ -1,13 +1,17 @@ import React, { SFC } from 'react'; import PropTypes from 'prop-types'; import { Field, Form } from 'react-final-form'; -import { useDispatch, useSelector } from 'react-redux'; import CardActions from '@material-ui/core/CardActions'; import Button from '@material-ui/core/Button'; import TextField from '@material-ui/core/TextField'; import CircularProgress from '@material-ui/core/CircularProgress'; import { makeStyles, Theme } from '@material-ui/core/styles'; -import { userLogin, ReduxState, useTranslate } from 'ra-core'; +import { + useTranslate, + useAuthProvider, + useNotify, + useSafeSetState, +} from 'ra-core'; interface Props { redirectTo?: string; @@ -48,9 +52,10 @@ const Input = ({ ); const LoginForm: SFC = ({ redirectTo }) => { - const dispatch = useDispatch(); - const loading = useSelector((state: ReduxState) => state.admin.loading > 0); + const [loading, setLoading] = useSafeSetState(false); + const { login } = useAuthProvider(); const translate = useTranslate(); + const notify = useNotify(); const classes = useStyles({}); const validate = (values: FormData) => { @@ -66,14 +71,29 @@ const LoginForm: SFC = ({ redirectTo }) => { }; const submit = values => { - dispatch(userLogin(values, redirectTo)); + setLoading(true); + login(values, redirectTo) + .then(() => { + setLoading(false); + }) + .catch(error => { + setLoading(false); + notify( + typeof error === 'string' + ? error + : typeof error === 'undefined' || !error.message + ? 'ra.auth.sign_in_error' + : error.message, + 'warning' + ); + }); }; return (
( + render={({ handleSubmit }) => (
@@ -102,7 +122,7 @@ const LoginForm: SFC = ({ redirectTo }) => { variant="contained" type="submit" color="primary" - disabled={submitting || loading} + disabled={loading} className={classes.button} > {loading && ( diff --git a/packages/ra-ui-materialui/src/auth/Logout.tsx b/packages/ra-ui-materialui/src/auth/Logout.tsx index 39e1ccbf069..c954d009868 100644 --- a/packages/ra-ui-materialui/src/auth/Logout.tsx +++ b/packages/ra-ui-materialui/src/auth/Logout.tsx @@ -1,12 +1,11 @@ import React, { useCallback, FunctionComponent } from 'react'; import PropTypes from 'prop-types'; -import { useDispatch } from 'react-redux'; import MenuItem, { MenuItemProps } from '@material-ui/core/MenuItem'; import { makeStyles, Theme } from '@material-ui/core/styles'; import ExitIcon from '@material-ui/icons/PowerSettingsNew'; import classnames from 'classnames'; -import { useTranslate, userLogout } from 'ra-core'; +import { useTranslate, useAuthProvider } from 'ra-core'; interface Props { className?: string; @@ -36,15 +35,13 @@ const LogoutWithRef: FunctionComponent< const { className, redirectTo, ...rest } = props; const classes = useStyles({}); // the empty {} is a temp fix for https://github.com/mui-org/material-ui/issues/15942 const translate = useTranslate(); - const dispatch = useDispatch(); + const { logout } = useAuthProvider(); // eslint-disable-next-line react-hooks/exhaustive-deps - const logout = useCallback(() => dispatch(userLogout(redirectTo)), [ - redirectTo, - ]); + const handleClick = useCallback(() => logout(redirectTo), [redirectTo]); return ( From 483a501d0cc68355854677f545113c507ca3d58b Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 6 Sep 2019 09:30:57 +0200 Subject: [PATCH 02/26] Publish one hook for each verb --- packages/ra-core/src/auth/index.ts | 19 +- packages/ra-core/src/auth/useAuthProvider.ts | 231 +----------------- packages/ra-core/src/auth/useAuthState.ts | 8 +- .../ra-core/src/auth/useAuthenticated.tsx | 8 +- packages/ra-core/src/auth/useCheckAuth.ts | 114 +++++++++ .../ra-core/src/auth/useGetPermissions.ts | 74 ++++++ packages/ra-core/src/auth/useLogin.ts | 72 ++++++ packages/ra-core/src/auth/useLogout.ts | 86 +++++++ packages/ra-core/src/auth/usePermissions.ts | 4 +- .../ra-ui-materialui/src/auth/LoginForm.tsx | 9 +- packages/ra-ui-materialui/src/auth/Logout.tsx | 4 +- 11 files changed, 379 insertions(+), 250 deletions(-) create mode 100644 packages/ra-core/src/auth/useCheckAuth.ts create mode 100644 packages/ra-core/src/auth/useGetPermissions.ts create mode 100644 packages/ra-core/src/auth/useLogin.ts create mode 100644 packages/ra-core/src/auth/useLogout.ts diff --git a/packages/ra-core/src/auth/index.ts b/packages/ra-core/src/auth/index.ts index 0cba9fe80b3..765a2649d96 100644 --- a/packages/ra-core/src/auth/index.ts +++ b/packages/ra-core/src/auth/index.ts @@ -5,13 +5,26 @@ import useAuthState from './useAuthState'; import usePermissions from './usePermissions'; import useAuthenticated from './useAuthenticated'; import WithPermissions from './WithPermissions'; +import useLogin from './useLogin'; +import useLogout from './useLogout'; +import useCheckAuth from './useCheckAuth'; +import useGetPermissions from './useGetPermissions'; export * from './types'; + export { AuthContext, - Authenticated, - WithPermissions, useAuthProvider, + // low-vevel hooks for calling a particular verb on the authProvider + useLogin, + useLogout, + useCheckAuth, + useGetPermissions, + // hooks with state management + usePermissions, useAuthState, + // hook with immediate effect useAuthenticated, - usePermissions, + // components + Authenticated, + WithPermissions, }; diff --git a/packages/ra-core/src/auth/useAuthProvider.ts b/packages/ra-core/src/auth/useAuthProvider.ts index 2a2f3d913d6..39aa088065e 100644 --- a/packages/ra-core/src/auth/useAuthProvider.ts +++ b/packages/ra-core/src/auth/useAuthProvider.ts @@ -1,237 +1,12 @@ -import { useCallback, useMemo, useContext } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { push } from 'connected-react-router'; -import { Location } from 'history'; +import { useContext } from 'react'; import AuthContext from './AuthContext'; -import { - AUTH_LOGIN, - AUTH_CHECK, - AUTH_LOGOUT, - AUTH_GET_PERMISSIONS, -} from './types'; -import { clearState } from '../actions/clearActions'; -import { ReduxState } from '../types'; -import useNotify from '../sideEffect/useNotify'; -const defaultParams = { +export const defaultAuthParams = { loginUrl: '/login', afterLoginUrl: '/', }; -const emptyArray = []; - -interface AuthCallbacks { - login: (params: any, pathName?: string) => Promise; - logout: (redirectTo: string) => Promise; - check: ( - location?: Location, - logoutOnFailure?: boolean, - redirectTo?: string - ) => Promise; - getPermissions: (location?: Location) => Promise; -} - -/** - * Hook for calling the authProvider. - * - * Returns a set of callbacks for each of the authProvider verbs. - * These callbacks return a Promise for the authProvider response. - * - * - login: calls the AUTH_LOGIN verb and redirects to the previous - * authenticated page (or the home page) on success. - * - * - logout: calls the AUTH_LOGOUT verb, redirects to the login page, - * and clears the Redux state. - * - * - check: calls the AUTH_CHECK verb. In case of rejection, - * redirects to the login page, displays a notification, and throws an error. - * - * - getPermissions: calls the AUTH_GET_PERMISSIONS verb. - * - * This is a low level hook. See those more specialized hooks - * for common authentication tasks, based on useAuthProvider. - * - * @see useAuthenticated - * @see useAuthState - * @see usePermissions - * - * @param {Object} authParams Any params you want to pass to the authProvider - * - * @returns {Object} { login, logout, checkAuth, getPermissions } callbacks for the authProvider - * - * @example - * - * import { useAuthProvider } from 'react-admin'; - * - * const LoginButton = () => { - * const [loading, setLoading] = useState(false); - * const { login } = useAuthProvider(); - * const handleClick = { - * setLoading(true); - * login({ username: 'john', password: 'p@ssw0rd' }, '/posts') - * .then(() => setLoading(false)); - * } - * return ; - * } - * - * const LogoutButton = () => { - * const { logout } = useAuthProvider(); - * const handleClick = () => logout(); - * return ; - * } - * - * const MyProtectedPage = () => { - * const { check } = useAuthProvider(); - * useEffect(() => { - * check().catch(() => {}); - * }, []); - * return

Private content: EZAEZEZAET

- * } // tip: use useAuthenticated() hook instead - * - * const MyPage = () => { - * const { check } = useAuthProvider(); - * const [authenticated, setAuthenticated] = useState(true); // optimistic auth - * useEffect(() => { - * check(undefined, false) - * .then() => setAuthenticated(true)) - * .catch(() => setAuthenticated(false)); - * }, []); - * return authenticated ? : ; - * } // tip: use useAuthState() hook instead - */ -const useAuthProvider = (authParams: any = defaultParams): AuthCallbacks => { - const authProvider = useContext(AuthContext); - const currentLocation = useSelector( - (state: ReduxState) => state.router.location - ); - const nextPathName = - currentLocation.state && currentLocation.state.nextPathname; - const dispatch = useDispatch(); - const notify = useNotify(); - - /** - * Log a user in by calling the authProvider AUTH_LOGIN verb - * - * @param {object} params Login parameters to pass to the authProvider. May contain username/email, password, etc - * @param {string} pathName The path to redirect to after login. By default, redirects to the home page, or to the last page visited after deconnexion. - * - * @return {Promise} The authProvider response - */ - const login = useCallback( - (params, pathName = authParams.afterLoginUrl) => - authProvider(AUTH_LOGIN, { ...params, ...authParams }).then(ret => { - dispatch(push(nextPathName || pathName)); - return ret; - }), - [authParams, authProvider, dispatch, nextPathName] - ); - - /** - * Log the current user out by calling the authProvider AUTH_LOGOUT verb, - * and redirect them to the login screen. - * - * @param {string} redirectTo The path name to redirect the user to (optional, defaults to login) - * - * @return {Promise} The authProvider response - */ - const logout = useCallback( - (redirectTo = authParams.loginUrl) => - authProvider(AUTH_LOGOUT, authParams).then( - redirectToFromProvider => { - dispatch( - push({ - pathname: redirectToFromProvider || redirectTo, - state: { - nextPathname: - currentLocation && currentLocation.pathname, - }, - }) - ); - dispatch(clearState()); - return redirectToFromProvider; - } - ), - [authParams, authProvider, currentLocation, dispatch] - ); - - /** - * Check if the current user is authenticated by calling the authProvider AUTH_CHECK verb. - * Logs the user out on failure. - * - * @param {Location} location The path name to check auth for - * @param {boolean} logoutOnFailure Whether the user should be logged out if the authProvider fails to authenticatde them. True by default. - * - * @return {Promise} Resolved to the authProvider response if the user passes the check, or rejected with an error otherwise - */ - const check = useCallback( - ( - location: Location = currentLocation, - logoutOnFailure = true, - redirectTo = authParams.loginUrl - ) => - authProvider(AUTH_CHECK, { - location, - ...authParams, - }).catch(error => { - if (logoutOnFailure) { - logout(redirectTo); - notify( - getErrorMessage(error, 'ra.auth.auth_check_error'), - 'warning' - ); - } - throw error; - }), - [authParams, authProvider, currentLocation, logout, notify] - ); - - const getPermissions = useCallback( - (location: Location = currentLocation) => - authProvider(AUTH_GET_PERMISSIONS, { location, ...authParams }), - [authParams, authProvider, currentLocation] - ); - - const callbacksForMissingAuthProvider = useMemo( - () => ({ - login: (_, __) => { - dispatch(push(authParams.afterLoginUrl)); - return Promise.resolve(); - }, - logout: _ => { - dispatch( - push({ - pathname: authParams.loginUrl, - state: { - nextPathname: - currentLocation && currentLocation.pathname, - }, - }) - ); - dispatch(clearState()); - return Promise.resolve(); - }, - check: () => Promise.resolve(true), - getPermissions: () => Promise.resolve(emptyArray), - }), - [ - authParams.afterLoginUrl, - authParams.loginUrl, - currentLocation, - dispatch, - ] - ); - - return authProvider - ? { login, logout, check, getPermissions } - : callbacksForMissingAuthProvider; -}; - -const getErrorMessage = (error, defaultMessage) => - typeof error === 'string' - ? error - : typeof error === 'undefined' || !error.message - ? defaultMessage - : error.message; +const useAuthProvider = () => useContext(AuthContext); export default useAuthProvider; diff --git a/packages/ra-core/src/auth/useAuthState.ts b/packages/ra-core/src/auth/useAuthState.ts index 8c26da071e4..f8367b11738 100644 --- a/packages/ra-core/src/auth/useAuthState.ts +++ b/packages/ra-core/src/auth/useAuthState.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; -import useAuthProvider from './useAuthProvider'; +import useCheckAuth from './useCheckAuth'; import { useSafeSetState } from '../util/hooks'; interface State { @@ -54,16 +54,16 @@ const useAuthState = (authParams: any = emptyParams): State => { loaded: false, authenticated: true, // optimistic }); - const { check } = useAuthProvider(authParams); + const checkAuth = useCheckAuth(authParams); useEffect(() => { - check(undefined, false) + checkAuth(undefined, false) .then(() => setState({ loading: false, loaded: true, authenticated: true }) ) .catch(() => setState({ loading: false, loaded: true, authenticated: false }) ); - }, [check, setState]); + }, [checkAuth, setState]); return state; }; diff --git a/packages/ra-core/src/auth/useAuthenticated.tsx b/packages/ra-core/src/auth/useAuthenticated.tsx index 33776ec006d..f5873a6b99b 100644 --- a/packages/ra-core/src/auth/useAuthenticated.tsx +++ b/packages/ra-core/src/auth/useAuthenticated.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import useAuthProvider from './useAuthProvider'; +import useCheckAuth from './useCheckAuth'; /** * Restrict access to children to authenticated users. @@ -27,8 +27,8 @@ import useAuthProvider from './useAuthProvider'; * ); */ export default authParams => { - const { check } = useAuthProvider(authParams); + const checkAuth = useCheckAuth(authParams); useEffect(() => { - check().catch(() => {}); - }, [check]); + checkAuth().catch(() => {}); + }, [checkAuth]); }; diff --git a/packages/ra-core/src/auth/useCheckAuth.ts b/packages/ra-core/src/auth/useCheckAuth.ts new file mode 100644 index 00000000000..99198890b5d --- /dev/null +++ b/packages/ra-core/src/auth/useCheckAuth.ts @@ -0,0 +1,114 @@ +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { push } from 'connected-react-router'; +import { Location } from 'history'; + +import useAuthProvider, { defaultAuthParams } from './useAuthProvider'; +import { AUTH_CHECK } from './types'; +import useLogout from './useLogout'; +import { ReduxState } from '../types'; +import useNotify from '../sideEffect/useNotify'; + +/** + * Get a callback for calling the authProvider with the AUTH_CHECK verb. + * In case of rejection, redirects to the login page, displays a notification, + * and throws an error. + * + * This is a low level hook. See those more specialized hooks + * for common authentication tasks, based on useAuthCheck. + * + * @see useAuthenticated + * @see useAuthState + * + * @param {Object} authParams Any params you want to pass to the authProvider + * + * @returns {Object} { login, logout, checkAuth, getPermissions } callbacks for the authProvider + * + * @example + * + * import { useCheckAuth } from 'react-admin'; + * + * const MyProtectedPage = () => { + * const checkAuth = useCheckAuth(); + * useEffect(() => { + * checkAuth().catch(() => {}); + * }, []); + * return

Private content: EZAEZEZAET

+ * } // tip: use useAuthenticated() hook instead + * + * const MyPage = () => { + * const checkAuth = usecheckAuth(); + * const [authenticated, setAuthenticated] = useState(true); // optimistic auth + * useEffect(() => { + * checkAuth(undefined, false) + * .then() => setAuthenticated(true)) + * .catch(() => setAuthenticated(false)); + * }, []); + * return authenticated ? : ; + * } // tip: use useAuthState() hook instead + */ +const useCheckAuth = (authParams: any = defaultAuthParams): CheckAuth => { + const authProvider = useAuthProvider(); + const currentLocation = useSelector( + (state: ReduxState) => state.router.location + ); + const dispatch = useDispatch(); + const notify = useNotify(); + const logout = useLogout(authParams); + + const checkAuth = useCallback( + ( + location: Location = currentLocation, + logoutOnFailure = true, + redirectTo = authParams.loginUrl + ) => + authProvider(AUTH_CHECK, { + location, + ...authParams, + }).catch(error => { + if (logoutOnFailure) { + logout(redirectTo); + notify( + getErrorMessage(error, 'ra.auth.auth_check_error'), + 'warning' + ); + } + throw error; + }), + [authParams, authProvider, currentLocation, logout, notify] + ); + + const checkAuthWithoutAuthProvider = useCallback( + (_, __) => { + dispatch(push(authParams.afterLoginUrl)); + return Promise.resolve(); + }, + [authParams.afterLoginUrl, dispatch] + ); + + return authProvider ? checkAuth : checkAuthWithoutAuthProvider; +}; + +/** + * Check if the current user is authenticated by calling the authProvider AUTH_CHECK verb. + * Logs the user out on failure. + * + * @param {Location} location The path name to check auth for + * @param {boolean} logoutOnFailure Whether the user should be logged out if the authProvider fails to authenticatde them. True by default. + * + * @return {Promise} Resolved to the authProvider response if the user passes the check, or rejected with an error otherwise + */ +type CheckAuth = ( + location?: Location, + logoutOnFailure?: boolean, + redirectTo?: string +) => Promise; + +const getErrorMessage = (error, defaultMessage) => + typeof error === 'string' + ? error + : typeof error === 'undefined' || !error.message + ? defaultMessage + : error.message; + +export default useCheckAuth; diff --git a/packages/ra-core/src/auth/useGetPermissions.ts b/packages/ra-core/src/auth/useGetPermissions.ts new file mode 100644 index 00000000000..d0be8ff35cc --- /dev/null +++ b/packages/ra-core/src/auth/useGetPermissions.ts @@ -0,0 +1,74 @@ +import { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { Location } from 'history'; + +import useAuthProvider, { defaultAuthParams } from './useAuthProvider'; +import { AUTH_GET_PERMISSIONS } from './types'; +import { ReduxState } from '../types'; + +const emptyArray = []; + +/** + * Get a callback for calling the authProvider with the AUTH_GET_PERMISSIONS verb. + * + * @see useAuthProvider + * @param {Object} authParams Any params you want to pass to the authProvider + * + * @returns {Function} getPermissions callback + * + * This is a low level hook. See those more specialized hooks + * offering state handling. + * + * @see usePermissions + * + * @example + * + * import { useGetPermissions } from 'react-admin'; + * + * const Roles = () => { + * const [permissions, setPermissions] = useState([]); + * const getPermissions = useGetPermissions(); + * useEffect(() => { + * getPermissions().then(permissions => setPermissions(permissions)) + * }, []) + * return ( + *
    + * {permissions.map((permission, key) => ( + *
  • {permission}
  • + * ))} + *
+ * ); + * } + */ +const useGetPermissions = ( + authParams: any = defaultAuthParams +): GetPermissions => { + const authProvider = useAuthProvider(); + const currentLocation = useSelector( + (state: ReduxState) => state.router.location + ); + + const getPermissions = useCallback( + (location: Location = currentLocation) => + authProvider(AUTH_GET_PERMISSIONS, { location, ...authParams }), + [authParams, authProvider, currentLocation] + ); + + const getPermissionsWithoutProvider = useCallback( + () => Promise.resolve(emptyArray), + [] + ); + + return authProvider ? getPermissions : getPermissionsWithoutProvider; +}; + +/** + * Ask the permissions to the authProvider using the AUTH_GET_PERMISSIONS verb + * + * @param {Location} location the current location from history (optional) + * + * @return {Promise} The authProvider response + */ +type GetPermissions = (location?: Location) => Promise; + +export default useGetPermissions; diff --git a/packages/ra-core/src/auth/useLogin.ts b/packages/ra-core/src/auth/useLogin.ts new file mode 100644 index 00000000000..b4296ae035f --- /dev/null +++ b/packages/ra-core/src/auth/useLogin.ts @@ -0,0 +1,72 @@ +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { push } from 'connected-react-router'; + +import useAuthProvider, { defaultAuthParams } from './useAuthProvider'; +import { AUTH_LOGIN } from './types'; +import { ReduxState } from '../types'; + +/** + * Get a callback for calling the authProvider with the AUTH_LOGIN verb + * and redirect to the previous authenticated page (or the home page) on success. + * + * @see useAuthProvider + * @param {Object} authParams Any params you want to pass to the authProvider + * + * @returns {Function} login callback + * + * @example + * + * import { useLogin } from 'react-admin'; + * + * const LoginButton = () => { + * const [loading, setLoading] = useState(false); + * const login = useLogin(); + * const handleClick = { + * setLoading(true); + * login({ username: 'john', password: 'p@ssw0rd' }, '/posts') + * .then(() => setLoading(false)); + * } + * return ; + * } + */ +const useLogin = (authParams: any = defaultAuthParams): Login => { + const authProvider = useAuthProvider(); + const currentLocation = useSelector( + (state: ReduxState) => state.router.location + ); + const nextPathName = + currentLocation.state && currentLocation.state.nextPathname; + const dispatch = useDispatch(); + + const login = useCallback( + (params, pathName = authParams.afterLoginUrl) => + authProvider(AUTH_LOGIN, { ...params, ...authParams }).then(ret => { + dispatch(push(nextPathName || pathName)); + return ret; + }), + [authParams, authProvider, dispatch, nextPathName] + ); + + const loginWithoutProvider = useCallback( + (_, __) => { + dispatch(push(authParams.afterLoginUrl)); + return Promise.resolve(); + }, + [authParams.afterLoginUrl, dispatch] + ); + + return authProvider ? login : loginWithoutProvider; +}; + +/** + * Log a user in by calling the authProvider AUTH_LOGIN verb + * + * @param {object} params Login parameters to pass to the authProvider. May contain username/email, password, etc + * @param {string} pathName The path to redirect to after login. By default, redirects to the home page, or to the last page visited after deconnexion. + * + * @return {Promise} The authProvider response + */ +type Login = (params: any, pathName?: string) => Promise; + +export default useLogin; diff --git a/packages/ra-core/src/auth/useLogout.ts b/packages/ra-core/src/auth/useLogout.ts new file mode 100644 index 00000000000..dc420b23cad --- /dev/null +++ b/packages/ra-core/src/auth/useLogout.ts @@ -0,0 +1,86 @@ +import { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { push } from 'connected-react-router'; + +import useAuthProvider, { defaultAuthParams } from './useAuthProvider'; +import { AUTH_LOGOUT } from './types'; +import { clearState } from '../actions/clearActions'; +import { ReduxState } from '../types'; + +/** + * Get a callback for calling the authProvider with the AUTH_LOGOUT verb, + * redirect to the login page, and clear the Redux state. + * + * @see useAuthProvider + * @param {Object} authParams Any params you want to pass to the authProvider + * + * @returns {Function} logout callback + * + * @example + * + * import { useLogout } from 'react-admin'; + * + * const LogoutButton = () => { + * const logout = useLogout(); + * const handleClick = () => logout(); + * return ; + * } + */ +const useLogout = (authParams: any = defaultAuthParams): Logout => { + const authProvider = useAuthProvider(); + const currentLocation = useSelector( + (state: ReduxState) => state.router.location + ); + const dispatch = useDispatch(); + + const logout = useCallback( + (redirectTo = authParams.loginUrl) => + authProvider(AUTH_LOGOUT, authParams).then( + redirectToFromProvider => { + dispatch( + push({ + pathname: redirectToFromProvider || redirectTo, + state: { + nextPathname: + currentLocation && currentLocation.pathname, + }, + }) + ); + dispatch(clearState()); + return redirectToFromProvider; + } + ), + [authParams, authProvider, currentLocation, dispatch] + ); + + const logoutWithoutProvider = useCallback( + _ => { + dispatch( + push({ + pathname: authParams.loginUrl, + state: { + nextPathname: + currentLocation && currentLocation.pathname, + }, + }) + ); + dispatch(clearState()); + return Promise.resolve(); + }, + [authParams.loginUrl, currentLocation, dispatch] + ); + + return authProvider ? logout : logoutWithoutProvider; +}; + +/** + * Log the current user out by calling the authProvider AUTH_LOGOUT verb, + * and redirect them to the login screen. + * + * @param {string} redirectTo The path name to redirect the user to (optional, defaults to login) + * + * @return {Promise} The authProvider response + */ +type Logout = (redirectTo: string) => Promise; + +export default useLogout; diff --git a/packages/ra-core/src/auth/usePermissions.ts b/packages/ra-core/src/auth/usePermissions.ts index 874687a5808..f040df5091e 100644 --- a/packages/ra-core/src/auth/usePermissions.ts +++ b/packages/ra-core/src/auth/usePermissions.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { useSelector } from 'react-redux'; -import useAuthProvider from './useAuthProvider'; +import useGetPermissions from './useGetPermissions'; import { useSafeSetState } from '../util/hooks'; import { ReduxState } from '../types'; @@ -50,7 +50,7 @@ const usePermissions = (authParams = emptyParams) => { loaded: false, }); const location = useSelector((state: ReduxState) => state.router.location); - const { getPermissions } = useAuthProvider(authParams); + const getPermissions = useGetPermissions(authParams); useEffect(() => { getPermissions(location) .then(permissions => { diff --git a/packages/ra-ui-materialui/src/auth/LoginForm.tsx b/packages/ra-ui-materialui/src/auth/LoginForm.tsx index 4e2c84cc677..f9a24e8eb58 100644 --- a/packages/ra-ui-materialui/src/auth/LoginForm.tsx +++ b/packages/ra-ui-materialui/src/auth/LoginForm.tsx @@ -6,12 +6,7 @@ import Button from '@material-ui/core/Button'; import TextField from '@material-ui/core/TextField'; import CircularProgress from '@material-ui/core/CircularProgress'; import { makeStyles, Theme } from '@material-ui/core/styles'; -import { - useTranslate, - useAuthProvider, - useNotify, - useSafeSetState, -} from 'ra-core'; +import { useTranslate, useLogin, useNotify, useSafeSetState } from 'ra-core'; interface Props { redirectTo?: string; @@ -53,7 +48,7 @@ const Input = ({ const LoginForm: SFC = ({ redirectTo }) => { const [loading, setLoading] = useSafeSetState(false); - const { login } = useAuthProvider(); + const login = useLogin(); const translate = useTranslate(); const notify = useNotify(); const classes = useStyles({}); diff --git a/packages/ra-ui-materialui/src/auth/Logout.tsx b/packages/ra-ui-materialui/src/auth/Logout.tsx index c954d009868..ff2a39f8cc4 100644 --- a/packages/ra-ui-materialui/src/auth/Logout.tsx +++ b/packages/ra-ui-materialui/src/auth/Logout.tsx @@ -5,7 +5,7 @@ import { makeStyles, Theme } from '@material-ui/core/styles'; import ExitIcon from '@material-ui/icons/PowerSettingsNew'; import classnames from 'classnames'; -import { useTranslate, useAuthProvider } from 'ra-core'; +import { useTranslate, useLogout } from 'ra-core'; interface Props { className?: string; @@ -35,7 +35,7 @@ const LogoutWithRef: FunctionComponent< const { className, redirectTo, ...rest } = props; const classes = useStyles({}); // the empty {} is a temp fix for https://github.com/mui-org/material-ui/issues/15942 const translate = useTranslate(); - const { logout } = useAuthProvider(); + const logout = useLogout(); // eslint-disable-next-line react-hooks/exhaustive-deps const handleClick = useCallback(() => logout(redirectTo), [redirectTo]); return ( From 67b22273fd0c712c1cb7922cae6ecdecfa192bff Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 6 Sep 2019 10:23:33 +0200 Subject: [PATCH 03/26] Better typing --- packages/ra-core/src/auth/useAuthProvider.ts | 6 +++++- packages/ra-ui-materialui/src/auth/Logout.tsx | 5 ++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/ra-core/src/auth/useAuthProvider.ts b/packages/ra-core/src/auth/useAuthProvider.ts index 39aa088065e..0b323f5f4a3 100644 --- a/packages/ra-core/src/auth/useAuthProvider.ts +++ b/packages/ra-core/src/auth/useAuthProvider.ts @@ -1,5 +1,6 @@ import { useContext } from 'react'; +import { AuthProvider } from '../types'; import AuthContext from './AuthContext'; export const defaultAuthParams = { @@ -7,6 +8,9 @@ export const defaultAuthParams = { afterLoginUrl: '/', }; -const useAuthProvider = () => useContext(AuthContext); +/** + * Get the authProvider stored in the context + */ +const useAuthProvider = (): AuthProvider => useContext(AuthContext); export default useAuthProvider; diff --git a/packages/ra-ui-materialui/src/auth/Logout.tsx b/packages/ra-ui-materialui/src/auth/Logout.tsx index ff2a39f8cc4..c190cfa5323 100644 --- a/packages/ra-ui-materialui/src/auth/Logout.tsx +++ b/packages/ra-ui-materialui/src/auth/Logout.tsx @@ -37,7 +37,10 @@ const LogoutWithRef: FunctionComponent< const translate = useTranslate(); const logout = useLogout(); // eslint-disable-next-line react-hooks/exhaustive-deps - const handleClick = useCallback(() => logout(redirectTo), [redirectTo]); + const handleClick = useCallback(() => logout(redirectTo), [ + redirectTo, + logout, + ]); return ( Date: Fri, 6 Sep 2019 10:26:00 +0200 Subject: [PATCH 04/26] Fix unit tests --- packages/ra-core/src/auth/useCheckAuth.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/ra-core/src/auth/useCheckAuth.ts b/packages/ra-core/src/auth/useCheckAuth.ts index 99198890b5d..34247f3ba05 100644 --- a/packages/ra-core/src/auth/useCheckAuth.ts +++ b/packages/ra-core/src/auth/useCheckAuth.ts @@ -79,11 +79,8 @@ const useCheckAuth = (authParams: any = defaultAuthParams): CheckAuth => { ); const checkAuthWithoutAuthProvider = useCallback( - (_, __) => { - dispatch(push(authParams.afterLoginUrl)); - return Promise.resolve(); - }, - [authParams.afterLoginUrl, dispatch] + (_, __) => Promise.resolve(), + [] ); return authProvider ? checkAuth : checkAuthWithoutAuthProvider; From eea7b838f078db192b5ccf0fca9e0d66cd5734f1 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 6 Sep 2019 10:33:54 +0200 Subject: [PATCH 05/26] remove useless memoization --- packages/ra-core/src/auth/useCheckAuth.ts | 11 +++-------- packages/ra-core/src/auth/useGetPermissions.ts | 9 ++------- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/ra-core/src/auth/useCheckAuth.ts b/packages/ra-core/src/auth/useCheckAuth.ts index 34247f3ba05..a1cf0eb0120 100644 --- a/packages/ra-core/src/auth/useCheckAuth.ts +++ b/packages/ra-core/src/auth/useCheckAuth.ts @@ -1,6 +1,5 @@ import { useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { push } from 'connected-react-router'; +import { useSelector } from 'react-redux'; import { Location } from 'history'; import useAuthProvider, { defaultAuthParams } from './useAuthProvider'; @@ -52,7 +51,6 @@ const useCheckAuth = (authParams: any = defaultAuthParams): CheckAuth => { const currentLocation = useSelector( (state: ReduxState) => state.router.location ); - const dispatch = useDispatch(); const notify = useNotify(); const logout = useLogout(authParams); @@ -78,14 +76,11 @@ const useCheckAuth = (authParams: any = defaultAuthParams): CheckAuth => { [authParams, authProvider, currentLocation, logout, notify] ); - const checkAuthWithoutAuthProvider = useCallback( - (_, __) => Promise.resolve(), - [] - ); - return authProvider ? checkAuth : checkAuthWithoutAuthProvider; }; +const checkAuthWithoutAuthProvider = (_, __) => Promise.resolve(); + /** * Check if the current user is authenticated by calling the authProvider AUTH_CHECK verb. * Logs the user out on failure. diff --git a/packages/ra-core/src/auth/useGetPermissions.ts b/packages/ra-core/src/auth/useGetPermissions.ts index d0be8ff35cc..16ecedb2f10 100644 --- a/packages/ra-core/src/auth/useGetPermissions.ts +++ b/packages/ra-core/src/auth/useGetPermissions.ts @@ -6,8 +6,6 @@ import useAuthProvider, { defaultAuthParams } from './useAuthProvider'; import { AUTH_GET_PERMISSIONS } from './types'; import { ReduxState } from '../types'; -const emptyArray = []; - /** * Get a callback for calling the authProvider with the AUTH_GET_PERMISSIONS verb. * @@ -54,14 +52,11 @@ const useGetPermissions = ( [authParams, authProvider, currentLocation] ); - const getPermissionsWithoutProvider = useCallback( - () => Promise.resolve(emptyArray), - [] - ); - return authProvider ? getPermissions : getPermissionsWithoutProvider; }; +const getPermissionsWithoutProvider = () => Promise.resolve([]); + /** * Ask the permissions to the authProvider using the AUTH_GET_PERMISSIONS verb * From 50807289fe2d90deb73adca8f9561d9bf6b09296 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 6 Sep 2019 10:57:18 +0200 Subject: [PATCH 06/26] Fix infinite loop --- packages/ra-core/src/auth/useAuthState.ts | 2 +- packages/ra-core/src/auth/useCheckAuth.ts | 23 ++++++----------------- packages/ra-core/src/auth/useLogout.ts | 2 +- 3 files changed, 8 insertions(+), 19 deletions(-) diff --git a/packages/ra-core/src/auth/useAuthState.ts b/packages/ra-core/src/auth/useAuthState.ts index f8367b11738..115113a4725 100644 --- a/packages/ra-core/src/auth/useAuthState.ts +++ b/packages/ra-core/src/auth/useAuthState.ts @@ -56,7 +56,7 @@ const useAuthState = (authParams: any = emptyParams): State => { }); const checkAuth = useCheckAuth(authParams); useEffect(() => { - checkAuth(undefined, false) + checkAuth(false) .then(() => setState({ loading: false, loaded: true, authenticated: true }) ) diff --git a/packages/ra-core/src/auth/useCheckAuth.ts b/packages/ra-core/src/auth/useCheckAuth.ts index a1cf0eb0120..61a0e0bd250 100644 --- a/packages/ra-core/src/auth/useCheckAuth.ts +++ b/packages/ra-core/src/auth/useCheckAuth.ts @@ -21,7 +21,7 @@ import useNotify from '../sideEffect/useNotify'; * * @param {Object} authParams Any params you want to pass to the authProvider * - * @returns {Object} { login, logout, checkAuth, getPermissions } callbacks for the authProvider + * @returns {Function} checkAuth callback * * @example * @@ -39,7 +39,7 @@ import useNotify from '../sideEffect/useNotify'; * const checkAuth = usecheckAuth(); * const [authenticated, setAuthenticated] = useState(true); // optimistic auth * useEffect(() => { - * checkAuth(undefined, false) + * checkAuth(false) * .then() => setAuthenticated(true)) * .catch(() => setAuthenticated(false)); * }, []); @@ -48,22 +48,12 @@ import useNotify from '../sideEffect/useNotify'; */ const useCheckAuth = (authParams: any = defaultAuthParams): CheckAuth => { const authProvider = useAuthProvider(); - const currentLocation = useSelector( - (state: ReduxState) => state.router.location - ); const notify = useNotify(); const logout = useLogout(authParams); const checkAuth = useCallback( - ( - location: Location = currentLocation, - logoutOnFailure = true, - redirectTo = authParams.loginUrl - ) => - authProvider(AUTH_CHECK, { - location, - ...authParams, - }).catch(error => { + (logoutOnFailure = true, redirectTo = authParams.loginUrl) => + authProvider(AUTH_CHECK, authParams).catch(error => { if (logoutOnFailure) { logout(redirectTo); notify( @@ -73,7 +63,7 @@ const useCheckAuth = (authParams: any = defaultAuthParams): CheckAuth => { } throw error; }), - [authParams, authProvider, currentLocation, logout, notify] + [authParams, authProvider, logout, notify] ); return authProvider ? checkAuth : checkAuthWithoutAuthProvider; @@ -85,13 +75,12 @@ const checkAuthWithoutAuthProvider = (_, __) => Promise.resolve(); * Check if the current user is authenticated by calling the authProvider AUTH_CHECK verb. * Logs the user out on failure. * - * @param {Location} location The path name to check auth for * @param {boolean} logoutOnFailure Whether the user should be logged out if the authProvider fails to authenticatde them. True by default. + * @param {string} redirectTo The login form url. Defaults to '/login' * * @return {Promise} Resolved to the authProvider response if the user passes the check, or rejected with an error otherwise */ type CheckAuth = ( - location?: Location, logoutOnFailure?: boolean, redirectTo?: string ) => Promise; diff --git a/packages/ra-core/src/auth/useLogout.ts b/packages/ra-core/src/auth/useLogout.ts index dc420b23cad..45cf3c1e3c2 100644 --- a/packages/ra-core/src/auth/useLogout.ts +++ b/packages/ra-core/src/auth/useLogout.ts @@ -37,6 +37,7 @@ const useLogout = (authParams: any = defaultAuthParams): Logout => { (redirectTo = authParams.loginUrl) => authProvider(AUTH_LOGOUT, authParams).then( redirectToFromProvider => { + dispatch(clearState()); dispatch( push({ pathname: redirectToFromProvider || redirectTo, @@ -46,7 +47,6 @@ const useLogout = (authParams: any = defaultAuthParams): Logout => { }, }) ); - dispatch(clearState()); return redirectToFromProvider; } ), From ee797b5a47ec5ab75637cd2a6316f82e374ebe2d Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 6 Sep 2019 10:58:40 +0200 Subject: [PATCH 07/26] Fix linter warning --- packages/ra-core/src/auth/useCheckAuth.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/ra-core/src/auth/useCheckAuth.ts b/packages/ra-core/src/auth/useCheckAuth.ts index 61a0e0bd250..7263900a876 100644 --- a/packages/ra-core/src/auth/useCheckAuth.ts +++ b/packages/ra-core/src/auth/useCheckAuth.ts @@ -1,11 +1,8 @@ import { useCallback } from 'react'; -import { useSelector } from 'react-redux'; -import { Location } from 'history'; import useAuthProvider, { defaultAuthParams } from './useAuthProvider'; import { AUTH_CHECK } from './types'; import useLogout from './useLogout'; -import { ReduxState } from '../types'; import useNotify from '../sideEffect/useNotify'; /** From 456bfea8fc296ecfdb6ec2dffe49a13a5fefc085 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 6 Sep 2019 11:04:14 +0200 Subject: [PATCH 08/26] Fix unit tests --- packages/ra-core/src/auth/Authenticated.spec.tsx | 4 ++-- packages/ra-core/src/auth/useAuthenticated.spec.tsx | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/ra-core/src/auth/Authenticated.spec.tsx b/packages/ra-core/src/auth/Authenticated.spec.tsx index af5a3059f4d..522aa950e42 100644 --- a/packages/ra-core/src/auth/Authenticated.spec.tsx +++ b/packages/ra-core/src/auth/Authenticated.spec.tsx @@ -45,9 +45,9 @@ describe('', () => { undoable: false, }) ); - expect(dispatch.mock.calls[1][0]).toEqual( + expect(dispatch.mock.calls[1][0]).toEqual({ type: 'RA/CLEAR_STATE' }); + expect(dispatch.mock.calls[2][0]).toEqual( push({ pathname: '/login', state: { nextPathname: '/' } }) ); - expect(dispatch.mock.calls[2][0]).toEqual({ type: 'RA/CLEAR_STATE' }); }); }); diff --git a/packages/ra-core/src/auth/useAuthenticated.spec.tsx b/packages/ra-core/src/auth/useAuthenticated.spec.tsx index a3e760038e9..afabb44bb0f 100644 --- a/packages/ra-core/src/auth/useAuthenticated.spec.tsx +++ b/packages/ra-core/src/auth/useAuthenticated.spec.tsx @@ -27,7 +27,6 @@ describe('useAuthenticated', () => { const payload = authProvider.mock.calls[0][1] as any; expect(payload.afterLoginUrl).toBe('/'); expect(payload.loginUrl).toBe('/login'); - expect(payload.location.pathname).toBe('/'); expect(dispatch).toHaveBeenCalledTimes(0); }); @@ -46,7 +45,6 @@ describe('useAuthenticated', () => { expect(authProvider.mock.calls[1][0]).toBe('AUTH_CHECK'); const payload = authProvider.mock.calls[1][1] as any; expect(payload.foo).toBe('bar'); - expect(payload.location.pathname).toBe('/'); expect(dispatch).toHaveBeenCalledTimes(0); }); @@ -82,9 +80,9 @@ describe('useAuthenticated', () => { undoable: false, }) ); - expect(dispatch.mock.calls[1][0]).toEqual( + expect(dispatch.mock.calls[1][0]).toEqual({ type: 'RA/CLEAR_STATE' }); + expect(dispatch.mock.calls[2][0]).toEqual( push({ pathname: '/login', state: { nextPathname: '/' } }) ); - expect(dispatch.mock.calls[2][0]).toEqual({ type: 'RA/CLEAR_STATE' }); }); }); From 15004f358d60d958e57acd9081fb5d78bf8ad6d5 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 6 Sep 2019 14:34:33 +0200 Subject: [PATCH 09/26] Fix performance penalty of useSelector --- packages/ra-core/src/auth/useLogout.ts | 25 ++++++++++++++++++------ packages/ra-core/src/createAdminStore.ts | 14 ++++++++++--- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/ra-core/src/auth/useLogout.ts b/packages/ra-core/src/auth/useLogout.ts index 45cf3c1e3c2..1709bf77c90 100644 --- a/packages/ra-core/src/auth/useLogout.ts +++ b/packages/ra-core/src/auth/useLogout.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useStore } from 'react-redux'; import { push } from 'connected-react-router'; import useAuthProvider, { defaultAuthParams } from './useAuthProvider'; @@ -28,9 +28,20 @@ import { ReduxState } from '../types'; */ const useLogout = (authParams: any = defaultAuthParams): Logout => { const authProvider = useAuthProvider(); - const currentLocation = useSelector( - (state: ReduxState) => state.router.location - ); + /** + * We need the current location to pass in the router state + * so that the login hook knows where to redirect to as next route after login. + * + * But if we used useSelector to read it from the store, the logout function + * would be rebuilt each time the user changes location. Consequently, that + * would force a rerender of the Logout button upon navigation. + * + * To avoid that, we don't subscribe to the store using useSelector; + * instead, we get a pointer to the store, and determine the location only + * after the logout function was called. + */ + + const store = useStore(); const dispatch = useDispatch(); const logout = useCallback( @@ -38,6 +49,7 @@ const useLogout = (authParams: any = defaultAuthParams): Logout => { authProvider(AUTH_LOGOUT, authParams).then( redirectToFromProvider => { dispatch(clearState()); + const currentLocation = store.getState().router.location; dispatch( push({ pathname: redirectToFromProvider || redirectTo, @@ -50,11 +62,12 @@ const useLogout = (authParams: any = defaultAuthParams): Logout => { return redirectToFromProvider; } ), - [authParams, authProvider, currentLocation, dispatch] + [authParams, authProvider, store, dispatch] ); const logoutWithoutProvider = useCallback( _ => { + const currentLocation = store.getState().router.location; dispatch( push({ pathname: authParams.loginUrl, @@ -67,7 +80,7 @@ const useLogout = (authParams: any = defaultAuthParams): Logout => { dispatch(clearState()); return Promise.resolve(); }, - [authParams.loginUrl, currentLocation, dispatch] + [authParams.loginUrl, store, dispatch] ); return authProvider ? logout : logoutWithoutProvider; diff --git a/packages/ra-core/src/createAdminStore.ts b/packages/ra-core/src/createAdminStore.ts index 0c17358904a..c6fd5ed35aa 100644 --- a/packages/ra-core/src/createAdminStore.ts +++ b/packages/ra-core/src/createAdminStore.ts @@ -49,9 +49,17 @@ export default ({ appReducer( action.type !== CLEAR_STATE ? state - : typeof initialState === 'function' - ? initialState() - : initialState, + : // Erase data from the store but keep location, notifications, etc. + // This allows e.g. to display a notification on logout + { + ...state, + admin: { + ...state.admin, + resources: {}, + customQueries: {}, + references: { oneToMany: {}, possibleValues: {} }, + }, + }, action ); const saga = function* rootSaga() { From 6966b92f618f9ef364671afdf47a8ad7aaacf402 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 6 Sep 2019 17:10:52 +0200 Subject: [PATCH 10/26] don't use old auth actions in CoreadminRouter --- packages/ra-core/src/CoreAdminRouter.tsx | 313 ++++++++---------- .../ra-core/src/auth/useGetPermissions.ts | 28 +- packages/ra-core/src/auth/useLogout.ts | 3 +- packages/ra-core/src/auth/usePermissions.ts | 7 +- packages/ra-core/src/createAdminStore.ts | 2 +- 5 files changed, 159 insertions(+), 194 deletions(-) diff --git a/packages/ra-core/src/CoreAdminRouter.tsx b/packages/ra-core/src/CoreAdminRouter.tsx index aa542cb2299..27ae5ee1696 100644 --- a/packages/ra-core/src/CoreAdminRouter.tsx +++ b/packages/ra-core/src/CoreAdminRouter.tsx @@ -1,22 +1,19 @@ import React, { Children, - Component, + useState, + useEffect, cloneElement, createElement, ComponentType, CSSProperties, ReactElement, + FunctionComponent, } from 'react'; -import { connect } from 'react-redux'; import { Route, Switch } from 'react-router-dom'; -import { AUTH_GET_PERMISSIONS } from './auth/types'; -import { isLoggedIn } from './reducer'; -import { userLogout as userLogoutAction } from './actions/authActions'; +import { useLogout, useGetPermissions, useAuthState } from './auth'; import RoutesWithLayout from './RoutesWithLayout'; -import AuthContext from './auth/AuthContext'; import { - Dispatch, AdminChildren, CustomRoutes, CatchAllComponent, @@ -41,51 +38,30 @@ export interface AdminRouterProps extends LayoutProps { loading: ComponentType; } -interface EnhancedProps { - isLoggedIn?: boolean; - userLogout: Dispatch; -} - -interface State { - children: ResourceElement[]; -} - -export class CoreAdminRouter extends Component< - AdminRouterProps & EnhancedProps, - State -> { - static defaultProps: Partial = { - customRoutes: [], - }; - static contextType = AuthContext; - state: State = { children: [] }; - - componentWillMount() { - this.initializeResources(this.props); - } +type State = ResourceElement[]; - initializeResources = (nextProps: AdminRouterProps & EnhancedProps) => { - if (typeof nextProps.children === 'function') { - this.initializeResourcesAsync(nextProps); +const CoreAdminRouter: FunctionComponent = props => { + const getPermissions = useGetPermissions(); + const doLogout = useLogout(); + const { authenticated } = useAuthState(); + const [computedChildren, setComputedChildren] = useState([]); + useEffect(() => { + if (typeof props.children === 'function') { + initializeResources(); } - }; + }, [authenticated]); // eslint-disable-line react-hooks/exhaustive-deps - initializeResourcesAsync = async ( - props: AdminRouterProps & EnhancedProps - ) => { - const authProvider = this.context; + const initializeResources = async () => { try { - const permissions = authProvider - ? await authProvider(AUTH_GET_PERMISSIONS) - : undefined; + const permissions = await getPermissions(); const resolveChildren = props.children as RenderResourcesFunction; const childrenFuncResult = resolveChildren(permissions); if ((childrenFuncResult as Promise).then) { (childrenFuncResult as Promise).then( - resolvedChildren => { - this.setState({ - children: resolvedChildren + resolvedChildren => + setComputedChildren( + resolvedChildren .filter(child => child) .map(child => ({ ...child, @@ -93,163 +69,144 @@ export class CoreAdminRouter extends Component< ...child.props, key: child.props.name, }, - })), - }); - } + })) + ) ); } else { - this.setState({ - children: (childrenFuncResult as ResourceElement[]).filter( + setComputedChildren( + (childrenFuncResult as ResourceElement[]).filter( child => child - ), - }); + ) + ); } } catch (error) { console.error(error); - this.props.userLogout(); + doLogout(); } }; - componentWillReceiveProps(nextProps) { - if (nextProps.isLoggedIn !== this.props.isLoggedIn) { - this.setState( - { - children: [], - }, - () => this.initializeResources(nextProps) - ); - } - } - - renderCustomRoutesWithoutLayout = (route, props) => { + const renderCustomRoutesWithoutLayout = (route, routeProps) => { if (route.props.render) { return route.props.render({ - ...props, - title: this.props.title, + ...routeProps, + title: props.title, }); } if (route.props.component) { return createElement(route.props.component, { - ...props, - title: this.props.title, + ...routeProps, + title: props.title, }); } }; - render() { - const { - layout, - catchAll, - children, - customRoutes, - dashboard, - loading, - logout, - menu, - theme, - title, - } = this.props; - - if ( - process.env.NODE_ENV !== 'production' && - typeof children !== 'function' && - !children - ) { - return ( -
- React-admin is properly configured. -
- Now you can add a first <Resource> as child of - <Admin>. -
- ); - } - - if ( - typeof children === 'function' && - (!this.state.children || this.state.children.length === 0) - ) { - return ; - } - - const childrenToRender = (typeof children === 'function' - ? this.state.children - : children) as Array>; - + const { + layout, + catchAll, + children, + customRoutes, + dashboard, + loading, + logout, + menu, + theme, + title, + } = props; + + if ( + process.env.NODE_ENV !== 'production' && + typeof children !== 'function' && + !children + ) { return ( -
- {// Render every resources children outside the React Router Switch - // as we need all of them and not just the one rendered - Children.map( - childrenToRender, - (child: React.ReactElement) => - cloneElement(child, { - key: child.props.name, - // The context prop instructs the Resource component to not render anything - // but simply to register itself as a known resource - intent: 'registration', - }) - )} - - {customRoutes - .filter(route => route.props.noLayout) - .map((route, key) => - cloneElement(route, { - key, - render: props => - this.renderCustomRoutesWithoutLayout( - route, - props - ), - }) - )} - - createElement( - layout, - { - dashboard, - logout, - menu, - theme, - title, - }, - !route.props.noLayout - )} - dashboard={dashboard} - title={title} - > - {Children.map( - childrenToRender, - ( - child: React.ReactElement< - ResourceProps - > - ) => - cloneElement(child, { - key: child.props.name, - intent: 'route', - }) - )} - - ) - } - /> - +
+ React-admin is properly configured. +
+ Now you can add a first <Resource> as child of + <Admin>.
); } -} -const mapStateToProps = state => ({ - isLoggedIn: isLoggedIn(state), -}); + if ( + typeof children === 'function' && + (!computedChildren || computedChildren.length === 0) + ) { + return ; + } + + const childrenToRender = (typeof children === 'function' + ? computedChildren + : children) as Array>; + + return ( +
+ {// Render every resources children outside the React Router Switch + // as we need all of them and not just the one rendered + Children.map( + childrenToRender, + (child: React.ReactElement) => + cloneElement(child, { + key: child.props.name, + // The context prop instructs the Resource component to not render anything + // but simply to register itself as a known resource + intent: 'registration', + }) + )} + + {customRoutes + .filter(route => route.props.noLayout) + .map((route, key) => + cloneElement(route, { + key, + render: routeProps => + renderCustomRoutesWithoutLayout( + route, + routeProps + ), + }) + )} + + createElement( + layout, + { + dashboard, + logout, + menu, + theme, + title, + }, + !route.props.noLayout + )} + dashboard={dashboard} + title={title} + > + {Children.map( + childrenToRender, + ( + child: React.ReactElement + ) => + cloneElement(child, { + key: child.props.name, + intent: 'route', + }) + )} + + ) + } + /> + +
+ ); +}; + +CoreAdminRouter.defaultProps = { + customRoutes: [], +}; -export default connect( - mapStateToProps, - { userLogout: userLogoutAction } -)(CoreAdminRouter) as ComponentType; +export default CoreAdminRouter; diff --git a/packages/ra-core/src/auth/useGetPermissions.ts b/packages/ra-core/src/auth/useGetPermissions.ts index 16ecedb2f10..7512cd25c97 100644 --- a/packages/ra-core/src/auth/useGetPermissions.ts +++ b/packages/ra-core/src/auth/useGetPermissions.ts @@ -1,10 +1,9 @@ import { useCallback } from 'react'; -import { useSelector } from 'react-redux'; +import { useStore } from 'react-redux'; import { Location } from 'history'; import useAuthProvider, { defaultAuthParams } from './useAuthProvider'; import { AUTH_GET_PERMISSIONS } from './types'; -import { ReduxState } from '../types'; /** * Get a callback for calling the authProvider with the AUTH_GET_PERMISSIONS verb. @@ -42,14 +41,27 @@ const useGetPermissions = ( authParams: any = defaultAuthParams ): GetPermissions => { const authProvider = useAuthProvider(); - const currentLocation = useSelector( - (state: ReduxState) => state.router.location - ); + /** + * We need the current location to pass to the authProvider for GET_PERMISSIONS. + * + * But if we used useSelector to read it from the store, the getPermissions function + * would be rebuilt each time the user changes location. Consequently, that + * would force a rerender of the enclosing component upon navigation. + * + * To avoid that, we don't subscribe to the store using useSelector; + * instead, we get a pointer to the store, and determine the location only + * after the getPermissions function was called. + */ + + const store = useStore(); const getPermissions = useCallback( - (location: Location = currentLocation) => - authProvider(AUTH_GET_PERMISSIONS, { location, ...authParams }), - [authParams, authProvider, currentLocation] + (location?: Location) => + authProvider(AUTH_GET_PERMISSIONS, { + location: location || store.getState().router.location, + ...authParams, + }), + [authParams, authProvider, store] ); return authProvider ? getPermissions : getPermissionsWithoutProvider; diff --git a/packages/ra-core/src/auth/useLogout.ts b/packages/ra-core/src/auth/useLogout.ts index 1709bf77c90..1e32e9215ad 100644 --- a/packages/ra-core/src/auth/useLogout.ts +++ b/packages/ra-core/src/auth/useLogout.ts @@ -5,7 +5,6 @@ import { push } from 'connected-react-router'; import useAuthProvider, { defaultAuthParams } from './useAuthProvider'; import { AUTH_LOGOUT } from './types'; import { clearState } from '../actions/clearActions'; -import { ReduxState } from '../types'; /** * Get a callback for calling the authProvider with the AUTH_LOGOUT verb, @@ -94,6 +93,6 @@ const useLogout = (authParams: any = defaultAuthParams): Logout => { * * @return {Promise} The authProvider response */ -type Logout = (redirectTo: string) => Promise; +type Logout = (redirectTo?: string) => Promise; export default useLogout; diff --git a/packages/ra-core/src/auth/usePermissions.ts b/packages/ra-core/src/auth/usePermissions.ts index f040df5091e..14705c01f74 100644 --- a/packages/ra-core/src/auth/usePermissions.ts +++ b/packages/ra-core/src/auth/usePermissions.ts @@ -1,9 +1,7 @@ import { useEffect } from 'react'; -import { useSelector } from 'react-redux'; import useGetPermissions from './useGetPermissions'; import { useSafeSetState } from '../util/hooks'; -import { ReduxState } from '../types'; interface State { loading: boolean; @@ -49,10 +47,9 @@ const usePermissions = (authParams = emptyParams) => { loading: true, loaded: false, }); - const location = useSelector((state: ReduxState) => state.router.location); const getPermissions = useGetPermissions(authParams); useEffect(() => { - getPermissions(location) + getPermissions() .then(permissions => { setState({ loading: false, loaded: true, permissions }); }) @@ -63,7 +60,7 @@ const usePermissions = (authParams = emptyParams) => { error, }); }); - }, [authParams, getPermissions, location, setState]); + }, [authParams, getPermissions, setState]); return state; }; diff --git a/packages/ra-core/src/createAdminStore.ts b/packages/ra-core/src/createAdminStore.ts index c6fd5ed35aa..35f7d6ab891 100644 --- a/packages/ra-core/src/createAdminStore.ts +++ b/packages/ra-core/src/createAdminStore.ts @@ -49,7 +49,7 @@ export default ({ appReducer( action.type !== CLEAR_STATE ? state - : // Erase data from the store but keep location, notifications, etc. + : // Erase data from the store but keep location, notifications, ui prefs, etc. // This allows e.g. to display a notification on logout { ...state, From 3f557b84b506a756da6d8b234999e95da5266b00 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 6 Sep 2019 17:11:05 +0200 Subject: [PATCH 11/26] Remove useless reducer --- .../ra-core/src/RoutesWithLayout.spec.tsx | 4 +-- packages/ra-core/src/reducer/admin/auth.ts | 26 ------------------- packages/ra-core/src/reducer/admin/index.ts | 4 --- packages/ra-core/src/reducer/index.ts | 2 -- .../ra-core/src/util/TestContext.spec.tsx | 3 --- 5 files changed, 1 insertion(+), 38 deletions(-) delete mode 100644 packages/ra-core/src/reducer/admin/auth.ts diff --git a/packages/ra-core/src/RoutesWithLayout.spec.tsx b/packages/ra-core/src/RoutesWithLayout.spec.tsx index c85ad4cfd3c..f55ac6009ba 100644 --- a/packages/ra-core/src/RoutesWithLayout.spec.tsx +++ b/packages/ra-core/src/RoutesWithLayout.spec.tsx @@ -15,9 +15,7 @@ describe('', () => { // the Provider is required because the dashboard is wrapped by , which is a connected component const store = createStore(() => ({ - admin: { - auth: { isLoggedIn: true }, - }, + admin: {}, router: { location: { pathname: '/' } }, })); diff --git a/packages/ra-core/src/reducer/admin/auth.ts b/packages/ra-core/src/reducer/admin/auth.ts deleted file mode 100644 index 3ed83ae5244..00000000000 --- a/packages/ra-core/src/reducer/admin/auth.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Reducer } from 'redux'; -import { USER_LOGIN_SUCCESS, USER_LOGOUT } from '../../actions'; - -const initialState = { isLoggedIn: false }; - -export interface Authstate { - isLoggedIn: boolean; -} - -const authReducer: Reducer = ( - previousState = initialState, - action -) => { - switch (action.type) { - case USER_LOGIN_SUCCESS: - return { ...previousState, isLoggedIn: true }; - case USER_LOGOUT: - return { ...previousState, isLoggedIn: false }; - } - - return previousState; -}; - -export const isLoggedIn = state => state.isLoggedIn; - -export default authReducer; diff --git a/packages/ra-core/src/reducer/admin/index.ts b/packages/ra-core/src/reducer/admin/index.ts index 91738a6f04b..15311e725f9 100644 --- a/packages/ra-core/src/reducer/admin/index.ts +++ b/packages/ra-core/src/reducer/admin/index.ts @@ -11,7 +11,6 @@ import references, { } from './references'; import saving from './saving'; import ui from './ui'; -import auth, { isLoggedIn as authIsLoggedIn } from './auth'; import customQueries from './customQueries'; export default combineReducers({ @@ -23,7 +22,6 @@ export default combineReducers({ references, saving, ui, - auth, }); export const getPossibleReferenceValues = (state, props) => @@ -35,6 +33,4 @@ export const getReferenceResource = (state, props) => { return resourceGetReferenceResource(state.resources, props); }; -export const isLoggedIn = state => authIsLoggedIn(state.auth); - export { getPossibleReferences } from './references'; diff --git a/packages/ra-core/src/reducer/index.ts b/packages/ra-core/src/reducer/index.ts index a4f1ad7a6fa..fc00d87c23f 100644 --- a/packages/ra-core/src/reducer/index.ts +++ b/packages/ra-core/src/reducer/index.ts @@ -4,7 +4,6 @@ import admin, { getResources as adminGetResources, getReferenceResource as adminGetReferenceResource, getPossibleReferenceValues as adminGetPossibleReferenceValues, - isLoggedIn as adminIsLoggedIn, } from './admin'; import i18nReducer, { getLocale as adminGetLocale } from './i18n'; export { getNotification } from './admin/notifications'; @@ -21,6 +20,5 @@ export const getPossibleReferenceValues = (state, props) => export const getResources = state => adminGetResources(state.admin); export const getReferenceResource = (state, props) => adminGetReferenceResource(state.admin, props); -export const isLoggedIn = state => adminIsLoggedIn(state.admin); export const getLocale = state => adminGetLocale(state.i18n); export { getPossibleReferences } from './admin'; diff --git a/packages/ra-core/src/util/TestContext.spec.tsx b/packages/ra-core/src/util/TestContext.spec.tsx index 189d23df4dc..10bf696e3b7 100644 --- a/packages/ra-core/src/util/TestContext.spec.tsx +++ b/packages/ra-core/src/util/TestContext.spec.tsx @@ -7,9 +7,6 @@ import { refreshView } from '../actions'; const primedStore = { admin: { - auth: { - isLoggedIn: false, - }, loading: 0, notifications: [], record: {}, From 2f52457d27df82c05b5da0e88df4113668c7cc05 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 6 Sep 2019 17:17:43 +0200 Subject: [PATCH 12/26] use useLogin in demo example --- examples/demo/src/layout/Login.js | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/examples/demo/src/layout/Login.js b/examples/demo/src/layout/Login.js index 30f8730ada0..afdc006d538 100644 --- a/examples/demo/src/layout/Login.js +++ b/examples/demo/src/layout/Login.js @@ -1,7 +1,6 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import { Field, Form } from 'react-final-form'; -import { useDispatch, useSelector } from 'react-redux'; import Avatar from '@material-ui/core/Avatar'; import Button from '@material-ui/core/Button'; @@ -13,7 +12,7 @@ import { createMuiTheme, makeStyles } from '@material-ui/core/styles'; import { ThemeProvider } from '@material-ui/styles'; import LockIcon from '@material-ui/icons/Lock'; -import { Notification, useTranslate, userLogin } from 'react-admin'; +import { Notification, useTranslate, useLogin } from 'react-admin'; import { lightTheme } from './themes'; @@ -72,15 +71,17 @@ const renderInput = ({ ); const Login = ({ location }) => { + const [loading, setLoading] = useState(false); const translate = useTranslate(); const classes = useStyles(); - const dispatch = useDispatch(); - const loading = useSelector(state => state.admin.loading > 0); + const doLogin = useLogin(); - const login = auth => - dispatch( - userLogin(auth, location.state ? location.state.nextPathname : '/') + const login = auth => { + setLoading(true); + doLogin(auth, location.state ? location.state.nextPathname : '/').then( + () => setLoading(false) ); + }; const validate = (values, props) => { const errors = {}; From ac9c5be3857691abbc96b4ed974c28e229724eee Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Fri, 6 Sep 2019 17:28:53 +0200 Subject: [PATCH 13/26] loading reducer no longer watches user checks --- packages/ra-core/src/CoreAdminRouter.spec.tsx | 3 +-- packages/ra-core/src/reducer/admin/loading.spec.ts | 9 --------- packages/ra-core/src/reducer/admin/loading.ts | 9 --------- 3 files changed, 1 insertion(+), 20 deletions(-) diff --git a/packages/ra-core/src/CoreAdminRouter.spec.tsx b/packages/ra-core/src/CoreAdminRouter.spec.tsx index a81b9a9a5c8..e246136916e 100644 --- a/packages/ra-core/src/CoreAdminRouter.spec.tsx +++ b/packages/ra-core/src/CoreAdminRouter.spec.tsx @@ -5,7 +5,7 @@ import { Router, Route } from 'react-router-dom'; import { createMemoryHistory } from 'history'; import renderWithRedux from './util/renderWithRedux'; -import { CoreAdminRouter } from './CoreAdminRouter'; +import CoreAdminRouter from './CoreAdminRouter'; import AuthContext from './auth/AuthContext'; import Resource from './Resource'; @@ -15,7 +15,6 @@ describe('', () => { afterEach(cleanup); const defaultProps = { - userLogout: () => , customRoutes: [], }; diff --git a/packages/ra-core/src/reducer/admin/loading.spec.ts b/packages/ra-core/src/reducer/admin/loading.spec.ts index 61c8635685e..6b59a8f1408 100644 --- a/packages/ra-core/src/reducer/admin/loading.spec.ts +++ b/packages/ra-core/src/reducer/admin/loading.spec.ts @@ -6,12 +6,6 @@ import { FETCH_CANCEL, } from '../../actions/fetchActions'; -import { - USER_LOGIN_LOADING, - USER_LOGIN_SUCCESS, - USER_LOGIN_FAILURE, -} from '../../actions/authActions'; - import reducer from './loading'; describe('loading reducer', () => { @@ -20,13 +14,10 @@ describe('loading reducer', () => { }); it('should increase with fetch or auth actions', () => { expect(reducer(0, { type: FETCH_START })).toEqual(1); - expect(reducer(0, { type: USER_LOGIN_LOADING })).toEqual(1); }); it('should decrease with fetch or auth actions success or failure', () => { expect(reducer(1, { type: FETCH_END })).toEqual(0); expect(reducer(1, { type: FETCH_ERROR })).toEqual(0); expect(reducer(1, { type: FETCH_CANCEL })).toEqual(0); - expect(reducer(1, { type: USER_LOGIN_SUCCESS })).toEqual(0); - expect(reducer(1, { type: USER_LOGIN_FAILURE })).toEqual(0); }); }); diff --git a/packages/ra-core/src/reducer/admin/loading.ts b/packages/ra-core/src/reducer/admin/loading.ts index b82a6e98f06..7a8032be138 100644 --- a/packages/ra-core/src/reducer/admin/loading.ts +++ b/packages/ra-core/src/reducer/admin/loading.ts @@ -6,24 +6,15 @@ import { FETCH_CANCEL, } from '../../actions/fetchActions'; -import { - USER_LOGIN_LOADING, - USER_LOGIN_SUCCESS, - USER_LOGIN_FAILURE, -} from '../../actions/authActions'; - type State = number; const loadingReducer: Reducer = (previousState = 0, { type }) => { switch (type) { case FETCH_START: - case USER_LOGIN_LOADING: return previousState + 1; case FETCH_END: case FETCH_ERROR: case FETCH_CANCEL: - case USER_LOGIN_SUCCESS: - case USER_LOGIN_FAILURE: return Math.max(previousState - 1, 0); default: return previousState; From 392379d29ca839d7ea8d03b43abfca0e90181638 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Sat, 7 Sep 2019 07:42:13 +0200 Subject: [PATCH 14/26] Show initial loader only if getPermissions takes more than one second Suspense-like! --- packages/ra-core/src/CoreAdminRouter.tsx | 8 +++++++- packages/ra-core/src/util/hooks.ts | 16 ++++++++++++++++ packages/ra-core/src/util/index.ts | 3 ++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/ra-core/src/CoreAdminRouter.tsx b/packages/ra-core/src/CoreAdminRouter.tsx index 27ae5ee1696..397eaf843f0 100644 --- a/packages/ra-core/src/CoreAdminRouter.tsx +++ b/packages/ra-core/src/CoreAdminRouter.tsx @@ -13,6 +13,7 @@ import { Route, Switch } from 'react-router-dom'; import { useLogout, useGetPermissions, useAuthState } from './auth'; import RoutesWithLayout from './RoutesWithLayout'; +import { useTimeout } from './util'; import { AdminChildren, CustomRoutes, @@ -44,6 +45,7 @@ const CoreAdminRouter: FunctionComponent = props => { const getPermissions = useGetPermissions(); const doLogout = useLogout(); const { authenticated } = useAuthState(); + const oneSecondHasPassed = useTimeout(1000); const [computedChildren, setComputedChildren] = useState([]); useEffect(() => { if (typeof props.children === 'function') { @@ -132,7 +134,11 @@ const CoreAdminRouter: FunctionComponent = props => { typeof children === 'function' && (!computedChildren || computedChildren.length === 0) ) { - return ; + if (oneSecondHasPassed) { + return ; + } else { + return null; + } } const childrenToRender = (typeof children === 'function' diff --git a/packages/ra-core/src/util/hooks.ts b/packages/ra-core/src/util/hooks.ts index 83e6c178b69..fafc4e93470 100644 --- a/packages/ra-core/src/util/hooks.ts +++ b/packages/ra-core/src/util/hooks.ts @@ -37,3 +37,19 @@ export function useDeepCompareEffect(callback, inputs) { }); const previousInputs = usePrevious(inputs); } + +export function useTimeout(ms = 0) { + const [ready, setReady] = useState(false); + + useEffect(() => { + let timer = setTimeout(() => { + setReady(true); + }, ms); + + return () => { + clearTimeout(timer); + }; + }, [ms]); + + return ready; +} diff --git a/packages/ra-core/src/util/index.ts b/packages/ra-core/src/util/index.ts index 27f5c5cadfc..8fd2a9af1be 100644 --- a/packages/ra-core/src/util/index.ts +++ b/packages/ra-core/src/util/index.ts @@ -11,7 +11,7 @@ import TestContext from './TestContext'; import renderWithRedux from './renderWithRedux'; import warning from './warning'; import useWhyDidYouUpdate from './useWhyDidYouUpdate'; -import { useSafeSetState } from './hooks'; +import { useSafeSetState, useTimeout } from './hooks'; export { downloadCSV, @@ -28,4 +28,5 @@ export { warning, useWhyDidYouUpdate, useSafeSetState, + useTimeout, }; From d0442c890ef6976fae1e9e4553688001183ba30d Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Sun, 8 Sep 2019 21:21:43 +0200 Subject: [PATCH 15/26] add useLogoutIfAccessDenied hook --- packages/ra-core/src/auth/index.ts | 2 + .../src/auth/useLogoutIfAccessDenied.spec.tsx | 74 +++++++++++++++++++ .../src/auth/useLogoutIfAccessDenied.ts | 54 ++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx create mode 100644 packages/ra-core/src/auth/useLogoutIfAccessDenied.ts diff --git a/packages/ra-core/src/auth/index.ts b/packages/ra-core/src/auth/index.ts index 765a2649d96..11c7760bd74 100644 --- a/packages/ra-core/src/auth/index.ts +++ b/packages/ra-core/src/auth/index.ts @@ -9,6 +9,7 @@ import useLogin from './useLogin'; import useLogout from './useLogout'; import useCheckAuth from './useCheckAuth'; import useGetPermissions from './useGetPermissions'; +import useLogoutIfAccessDenied from './useLogoutIfAccessDenied'; export * from './types'; export { @@ -24,6 +25,7 @@ export { useAuthState, // hook with immediate effect useAuthenticated, + useLogoutIfAccessDenied, // components Authenticated, WithPermissions, diff --git a/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx b/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx new file mode 100644 index 00000000000..2aebd82bb1f --- /dev/null +++ b/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import expect from 'expect'; +import { render, cleanup, wait } from '@testing-library/react'; + +import useLogoutIfAccessDenied from './useLogoutIfAccessDenied'; +import AuthContext from './AuthContext'; +import useLogout from './useLogout'; +import useNotify from '../sideEffect/useNotify'; + +jest.mock('./useLogout'); +jest.mock('../sideEffect/useNotify'); + +const TestComponent = ({ error }: { error?: any }) => { + const logoutIfAccessDenied = useLogoutIfAccessDenied(); + logoutIfAccessDenied(error); + return
rendered
; +}; + +const authProvider = (type, params) => + new Promise((resolve, reject) => { + if (type !== 'AUTH_ERROR') reject('bad method'); + if (params instanceof Error && params.message == 'denied') { + reject(new Error('logout')); + } + resolve(); + }); + +describe('useLogoutIfAccessDenied', () => { + afterEach(cleanup); + it('should not logout if passed no error', async () => { + const logout = jest.fn(); + useLogout.mockImplementationOnce(() => logout); + const notify = jest.fn(); + useNotify.mockImplementationOnce(() => notify); + render( + + + + ); + await wait(); + expect(logout).toHaveBeenCalledTimes(0); + expect(notify).toHaveBeenCalledTimes(0); + }); + + it('should not log out if passed an error that does not make the authProvider throw', async () => { + const logout = jest.fn(); + useLogout.mockImplementationOnce(() => logout); + const notify = jest.fn(); + useNotify.mockImplementationOnce(() => notify); + render( + + + + ); + await wait(); + expect(logout).toHaveBeenCalledTimes(0); + expect(notify).toHaveBeenCalledTimes(0); + }); + + it('should logout if passed an error that makes the authProvider throw', async () => { + const logout = jest.fn(); + useLogout.mockImplementationOnce(() => logout); + const notify = jest.fn(); + useNotify.mockImplementationOnce(() => notify); + render( + + + + ); + await wait(); + expect(logout).toHaveBeenCalledTimes(1); + expect(notify).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts b/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts new file mode 100644 index 00000000000..bce2d4d1804 --- /dev/null +++ b/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts @@ -0,0 +1,54 @@ +import { useCallback } from 'react'; + +import { AUTH_ERROR } from './types'; +import useAuthProvider from './useAuthProvider'; +import useLogout from './useLogout'; +import { useNotify } from '../sideEffect'; + +/** + * Returns a callback used to call the authProvidr with the AUTH_ERROR verb + * and an error from the dataProvider. If the authProvider rejects the call, + * the hook logs the user out and shows a logget out notification. + * + * Used in the useDataProvider hook to check for access denied responses + * (e.g. 401 or 403 responses) and trigger a logout. + * + * @see useLogout + * @see useDataProvider + * + * @returns {Function} logoutIfAccessDenied callback + * + * @example + * + * import { useLogoutIfAccessDenied, useNotify, DataProviderContext } from 'react-admin'; + * + * const FetchRestrictedResource = () => { + * const dataProvider = useContext(DataProviderContext); + * const logoutIfAccessDenied = useLogoutIfAccessDenied(); + * const notify = useNotify() + * useEffect(() => { + * dataProvider('GET_ONE', 'secret', { id: 123 }) + * .catch(error => { + * logoutIfaccessDenied(error); + * notify('server error', 'warning'); + * }) + * }, []); + * // ... + * } + */ +const useLogoutIfAccessDenied = () => { + const authProvider = useAuthProvider(); + const logout = useLogout(); + const notify = useNotify(); + const logoutIfAccessDenied = useCallback( + (error?: any) => + authProvider(AUTH_ERROR, error).catch(e => { + logout(); + notify('ra.notification.logged_out', 'warning'); + }), + [authProvider, logout, notify] + ); + return logoutIfAccessDenied; +}; + +export default useLogoutIfAccessDenied; From 55f25999314fdf5e9ed4539d428b0fbf60b828f9 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Sun, 8 Sep 2019 22:17:33 +0200 Subject: [PATCH 16/26] Use the new hook in dataProvider --- .../src/auth/useLogoutIfAccessDenied.ts | 25 +++++-- .../src/dataProvider/useDataProvider.ts | 70 +++++++++++-------- 2 files changed, 60 insertions(+), 35 deletions(-) diff --git a/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts b/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts index bce2d4d1804..a3a3308e1b0 100644 --- a/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts +++ b/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts @@ -8,7 +8,7 @@ import { useNotify } from '../sideEffect'; /** * Returns a callback used to call the authProvidr with the AUTH_ERROR verb * and an error from the dataProvider. If the authProvider rejects the call, - * the hook logs the user out and shows a logget out notification. + * the hook logs the user out and shows a logged out notification. * * Used in the useDataProvider hook to check for access denied responses * (e.g. 401 or 403 responses) and trigger a logout. @@ -36,19 +36,32 @@ import { useNotify } from '../sideEffect'; * // ... * } */ -const useLogoutIfAccessDenied = () => { +const useLogoutIfAccessDenied = (): LogoutIfAccessDenied => { const authProvider = useAuthProvider(); const logout = useLogout(); const notify = useNotify(); const logoutIfAccessDenied = useCallback( (error?: any) => - authProvider(AUTH_ERROR, error).catch(e => { - logout(); - notify('ra.notification.logged_out', 'warning'); - }), + authProvider(AUTH_ERROR, error) + .then(() => false) + .catch(e => { + logout(); + notify('ra.notification.logged_out', 'warning'); + return true; + }), [authProvider, logout, notify] ); return logoutIfAccessDenied; }; +/** + * Call the authProvidr with the AUTH_ERROR verb and the error passed as argument. + * If the authProvider rejects the call, logs the user out and shows a logged out notification. + * + * @param {Error} error An Error object (usually returned by the dataProvider) + * + * @return {Promise} Resolved to true if there was a logout, false otherwise + */ +type LogoutIfAccessDenied = (error?: any) => Promise; + export default useLogoutIfAccessDenied; diff --git a/packages/ra-core/src/dataProvider/useDataProvider.ts b/packages/ra-core/src/dataProvider/useDataProvider.ts index 020925a9618..d0495599a25 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.ts +++ b/packages/ra-core/src/dataProvider/useDataProvider.ts @@ -13,6 +13,7 @@ import { FETCH_END, FETCH_ERROR, FETCH_START } from '../actions/fetchActions'; import { showNotification } from '../actions/notificationActions'; import { refreshView } from '../actions/uiActions'; import { ReduxState, DataProvider } from '../types'; +import useLogoutIfAccessDenied from '../auth/useLogoutIfAccessDenied'; export type DataProviderHookFunction = ( type: string, @@ -77,6 +78,7 @@ const useDataProvider = (): DataProviderHookFunction => { const isOptimistic = useSelector( (state: ReduxState) => state.admin.ui.optimistic ); + const logoutIfAccessDenied = useLogoutIfAccessDenied(); return useCallback( ( @@ -119,12 +121,13 @@ const useDataProvider = (): DataProviderHookFunction => { onFailure, dataProvider, dispatch, + logoutIfAccessDenied, }; return undoable ? performUndoableQuery(params) : performQuery(params); }, - [dataProvider, dispatch, isOptimistic] + [dataProvider, dispatch, isOptimistic, logoutIfAccessDenied] ); }; @@ -147,6 +150,7 @@ const performUndoableQuery = ({ onFailure, dataProvider, dispatch, + logoutIfAccessDenied, }: QueryFunctionParams) => { dispatch(startOptimisticMode()); dispatch({ @@ -196,21 +200,24 @@ const performUndoableQuery = ({ dispatch({ type: FETCH_END }); }) .catch(error => { - dispatch({ - type: `${action}_FAILURE`, - error: error.message ? error.message : error, - payload: error.body ? error.body : null, - requestPayload: payload, - meta: { - ...rest, - resource, - fetchResponse: type, - fetchStatus: FETCH_ERROR, - }, + logoutIfAccessDenied(error).then(loggedOut => { + if (loggedOut) return; + dispatch({ + type: `${action}_FAILURE`, + error: error.message ? error.message : error, + payload: error.body ? error.body : null, + requestPayload: payload, + meta: { + ...rest, + resource, + fetchResponse: type, + fetchStatus: FETCH_ERROR, + }, + }); + dispatch({ type: FETCH_ERROR, error }); + onFailure && onFailure(error); + throw new Error(error.message ? error.message : error); }); - dispatch({ type: FETCH_ERROR, error }); - onFailure && onFailure(error); - throw new Error(error.message ? error.message : error); }); }); return Promise.resolve({}); @@ -232,6 +239,7 @@ const performQuery = ({ onFailure, dataProvider, dispatch, + logoutIfAccessDenied, }: QueryFunctionParams) => { dispatch({ type: action, @@ -266,21 +274,24 @@ const performQuery = ({ return response; }) .catch(error => { - dispatch({ - type: `${action}_FAILURE`, - error: error.message ? error.message : error, - payload: error.body ? error.body : null, - requestPayload: payload, - meta: { - ...rest, - resource, - fetchResponse: type, - fetchStatus: FETCH_ERROR, - }, + logoutIfAccessDenied(error).then(loggedOut => { + if (loggedOut) return; + dispatch({ + type: `${action}_FAILURE`, + error: error.message ? error.message : error, + payload: error.body ? error.body : null, + requestPayload: payload, + meta: { + ...rest, + resource, + fetchResponse: type, + fetchStatus: FETCH_ERROR, + }, + }); + dispatch({ type: FETCH_ERROR, error }); + onFailure && onFailure(error); + throw new Error(error.message ? error.message : error); }); - dispatch({ type: FETCH_ERROR, error }); - onFailure && onFailure(error); - throw new Error(error.message ? error.message : error); }); }; @@ -296,6 +307,7 @@ interface QueryFunctionParams { onFailure?: (error: any) => void; dataProvider: DataProvider; dispatch: Dispatch; + logoutIfAccessDenied: (error?: any) => Promise; } export default useDataProvider; From b57de41ed126b13d76dbb528ed9ab50d9a03b198 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Sun, 8 Sep 2019 22:35:52 +0200 Subject: [PATCH 17/26] Test return value of useLogoutIfAccessDenied --- .../src/auth/useLogoutIfAccessDenied.spec.tsx | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx b/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx index 2aebd82bb1f..ae3e94ca4dd 100644 --- a/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx +++ b/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import expect from 'expect'; import { render, cleanup, wait } from '@testing-library/react'; @@ -10,10 +10,18 @@ import useNotify from '../sideEffect/useNotify'; jest.mock('./useLogout'); jest.mock('../sideEffect/useNotify'); +const logout = jest.fn(); +useLogout.mockImplementation(() => logout); +const notify = jest.fn(); +useNotify.mockImplementation(() => notify); + const TestComponent = ({ error }: { error?: any }) => { + const [loggedOut, setLoggedOut] = useState(false); const logoutIfAccessDenied = useLogoutIfAccessDenied(); - logoutIfAccessDenied(error); - return
rendered
; + useEffect(() => { + logoutIfAccessDenied(error).then(setLoggedOut); + }, [error, logoutIfAccessDenied]); + return
{loggedOut ? '' : 'logged in'}
; }; const authProvider = (type, params) => @@ -27,12 +35,9 @@ const authProvider = (type, params) => describe('useLogoutIfAccessDenied', () => { afterEach(cleanup); + it('should not logout if passed no error', async () => { - const logout = jest.fn(); - useLogout.mockImplementationOnce(() => logout); - const notify = jest.fn(); - useNotify.mockImplementationOnce(() => notify); - render( + const { queryByText } = render( @@ -40,14 +45,11 @@ describe('useLogoutIfAccessDenied', () => { await wait(); expect(logout).toHaveBeenCalledTimes(0); expect(notify).toHaveBeenCalledTimes(0); + expect(queryByText('logged in')).not.toBeNull(); }); it('should not log out if passed an error that does not make the authProvider throw', async () => { - const logout = jest.fn(); - useLogout.mockImplementationOnce(() => logout); - const notify = jest.fn(); - useNotify.mockImplementationOnce(() => notify); - render( + const { queryByText } = render( @@ -55,14 +57,11 @@ describe('useLogoutIfAccessDenied', () => { await wait(); expect(logout).toHaveBeenCalledTimes(0); expect(notify).toHaveBeenCalledTimes(0); + expect(queryByText('logged in')).not.toBeNull(); }); it('should logout if passed an error that makes the authProvider throw', async () => { - const logout = jest.fn(); - useLogout.mockImplementationOnce(() => logout); - const notify = jest.fn(); - useNotify.mockImplementationOnce(() => notify); - render( + const { queryByText } = render( @@ -70,5 +69,6 @@ describe('useLogoutIfAccessDenied', () => { await wait(); expect(logout).toHaveBeenCalledTimes(1); expect(notify).toHaveBeenCalledTimes(1); + expect(queryByText('logged in')).toBeNull(); }); }); From a40b274f800df4ccfa57ce32c770ba997cf1aee4 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Sun, 8 Sep 2019 23:15:00 +0200 Subject: [PATCH 18/26] Update authentication documentation --- UPGRADE.md | 8 +- docs/Authentication.md | 96 +++++++++++-------- docs/_layouts/default.html | 8 +- .../ra-core/src/auth/useAuthenticated.tsx | 4 +- 4 files changed, 73 insertions(+), 43 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index ead9618178c..dc4e0a1a7d6 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -347,7 +347,13 @@ When you provide an `authProvider` to the `` component, react-admin creat 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`). +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 auth hooks: + +- `useLogin` +- `useLogout` +- `useAuthenticated` +- `useAuthState` +- `usePermissions` ## `authProvider` No Longer Receives `match` in Params diff --git a/docs/Authentication.md b/docs/Authentication.md index e22ea196517..c60aa71ff06 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -29,12 +29,13 @@ What's an `authProvider`? Just like a `dataProvider`, an `authProvider` is a fun ```js // in src/authProvider.js +// type is one of AUTH_LOGIN, AUTH_LOGOUT, AUTH_ERROR, AUTH_CHECK, and AUTH_GET_PERMISSIONS const authProvider = (type, params) => Promise.resolve; export default authProvider; ``` -Let's see when react-admin calls the `authProvider`, and with which params. +Let's see when react-admin calls the `authProvider`, and how to write one for your own authentication provider. ## Login Configuration @@ -266,25 +267,23 @@ But what if you want to use an email instead of a username? What if you want to For all these cases, it's up to you to implement your own `LoginPage` component, which will be displayed under the `/login` route instead of the default username/password form, and your own `LogoutButton` component, which will be displayed in the sidebar. Pass both these components to the `` component: -**Tip**: Use the `userLogin` and `userLogout` actions in your custom `Login` and `Logout` components. +**Tip**: Use the `useLogin` and `useLogout` hooks in your custom `Login` and `Logout` components. ```jsx // in src/MyLoginPage.js import React, { useState } from 'react'; import { useDispatch } from 'react-redux'; -import { userLogin } from 'react-admin'; +import { useLogin } from 'react-admin'; import { ThemeProvider } from '@material-ui/styles'; const MyLoginPage = ({ theme }) => { const dispatch = useDispatch() const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); + const login = useLogin(); const submit = (e) => { e.preventDefault(); - // gather your data/credentials here - const credentials = { email, password }; - // Dispatch the userLogin action - dispatch(userLogin(credentials)); + login({ email, password }); } return ( @@ -302,16 +301,17 @@ export default MyLoginPage; // in src/MyLogoutButton.js import React, { forwardRef } from 'react'; import { useDispatch } from 'react-redux'; -import { userLogout } from 'react-admin'; +import { useLogout } from 'react-admin'; import MenuItem from '@material-ui/core/MenuItem'; import ExitIcon from '@material-ui/icons/PowerSettingsNew'; const MyLogoutButton = forwardRef((props, ref) => { const dispatch = useDispatch(); - const logout = () => dispatch(userLogout(redirectTo)); + const logout = useLogout(); + const handleClick = () => logout(); return ( Logout @@ -332,30 +332,25 @@ 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: +**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 `logout()` function: ```diff // in src/MyLogoutButton.js // ... - dispatch(userLogout())} -+ onClick={() => dispatch(userLogout('/custom-login'))} - {...props} - > - Logout - +- const handleClick = () => logout(); ++ const handleClick = () => logout('/custom-login'); ``` -## `useAuth()` Hook +## `useAuthenticated()` 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. +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 `useAuthenticated()` hook, which calls the `authProvider` with the `AUTH_CHECK` type on mount, and redirects to login if it returns a rejected Promise. ```jsx // in src/MyPage.js -import { useAuth } from 'react-admin'; +import { useAuthenticated } from 'react-admin'; const MyPage = () => { - useAuth(); // redirects to login if not authenticated + useAuthenticated(); // redirects to login if not authenticated return (
... @@ -366,11 +361,11 @@ const MyPage = () => { export default MyPage; ``` -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: +If you call `useAuthenticated()` 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' }) + useAuthenticated({ foo: 'bar' }); // calls authProvider(AUTH_CHECK, { foo: 'bar' }) return (
... @@ -379,28 +374,51 @@ const MyPage = () => { }; ``` -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. +The `useAuthenticated` 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. -To avoid rendering a component and force waiting for the `authProvider` response, use the return value of the `useAuth()` hook. +## `` Component + +The `` component uses the `useAuthenticated()` hook, and renders its child component - unless the authentication check fails. Use it as an alternative to the `useAuthenticated()` hook when you can't use a hook, e.g. inside a `Route` `render` function: ```jsx -const MyPage = () => { - const { loaded } = useAuth(); - return loaded ? ( -
- ... -
- ) : null; -}; +import { Authenticated } from 'react-admin'; + +const CustomRoutes = [ + + + + + } /> +]; +const App = () => ( + + ... + +); ``` -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: +## `useAuthState()` Hook + +To avoid rendering a component and force waiting for the `authProvider` response, use the `useAuthState()` hook instead of the `useAuthenticated()` hook. It returns an object with 3 properties: + +- `loading`: `true` just after mount, while the `authProvider` is being called. `false` once the `authProvider` has answered +- `loaded`: the opposite of `loading`. +- `connected`: `undefined` while loading. then `true` or `false` depending on the `authProvider` response. + + +You can render different content depending on the authenticated status. ```jsx +import { useAuthState } from 'react-admin'; + const MyPage = () => { - const { loaded, authenticated } = useAuth({}, { logoutOnFailure: false }); - if (!loaded) return null; - if (!authenticated) return ; - return + const { loading, authenticated } = useAuthState(); + if (loading) { + return ; + } + if (authenticated) { + return ; + } + return ; }; ``` diff --git a/docs/_layouts/default.html b/docs/_layouts/default.html index d985d1b910b..59e8e94211e 100644 --- a/docs/_layouts/default.html +++ b/docs/_layouts/default.html @@ -641,7 +641,13 @@ Components
  • - useAuth() Hook + useAuthenticated() Hook +
  • +
  • + <Authenticated> Component +
  • +
  • + useAuthState() Hook
  • diff --git a/packages/ra-core/src/auth/useAuthenticated.tsx b/packages/ra-core/src/auth/useAuthenticated.tsx index f5873a6b99b..4f57ac6865d 100644 --- a/packages/ra-core/src/auth/useAuthenticated.tsx +++ b/packages/ra-core/src/auth/useAuthenticated.tsx @@ -2,8 +2,8 @@ import { useEffect } from 'react'; import useCheckAuth from './useCheckAuth'; /** - * Restrict access to children to authenticated users. - * Redirects anonymous users to the login page. + * Restrict access to authenticated users. + * Redirect anonymous users to the login page. * * Use it in your custom page components to require * authentication. From 9bb4e64e93164de4a1f476ca0f97e6be4fad7acd Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Sun, 8 Sep 2019 23:20:55 +0200 Subject: [PATCH 19/26] fix typo in Authorization documentation --- docs/Authorization.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/Authorization.md b/docs/Authorization.md index c9dd64b8c4b..cceb43ea588 100644 --- a/docs/Authorization.md +++ b/docs/Authorization.md @@ -11,7 +11,7 @@ By default, a react-admin app doesn't check authorization. However, if needed, i ## Configuring the Auth Provider -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']`). +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 an array of roles (e.g. `['post_editor', 'comment_moderator', 'super_admin']`). 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`: @@ -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_GET_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 From f7b45182a2153a3750d79175572416684636e860 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Sun, 8 Sep 2019 23:54:36 +0200 Subject: [PATCH 20/26] Fix unit tests --- packages/ra-core/src/auth/useLogoutIfAccessDenied.ts | 6 +++++- packages/ra-core/src/dataProvider/useDataProvider.ts | 12 ++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts b/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts index a3a3308e1b0..4efe1a47480 100644 --- a/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts +++ b/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts @@ -51,9 +51,13 @@ const useLogoutIfAccessDenied = (): LogoutIfAccessDenied => { }), [authProvider, logout, notify] ); - return logoutIfAccessDenied; + return authProvider + ? logoutIfAccessDenied + : logoutIfAccessDeniedWithoutProvider; }; +const logoutIfAccessDeniedWithoutProvider = () => Promise.resolve(false); + /** * Call the authProvidr with the AUTH_ERROR verb and the error passed as argument. * If the authProvider rejects the call, logs the user out and shows a logged out notification. diff --git a/packages/ra-core/src/dataProvider/useDataProvider.ts b/packages/ra-core/src/dataProvider/useDataProvider.ts index d0495599a25..1ebb627d3cb 100644 --- a/packages/ra-core/src/dataProvider/useDataProvider.ts +++ b/packages/ra-core/src/dataProvider/useDataProvider.ts @@ -199,7 +199,7 @@ const performUndoableQuery = ({ }); dispatch({ type: FETCH_END }); }) - .catch(error => { + .catch(error => logoutIfAccessDenied(error).then(loggedOut => { if (loggedOut) return; dispatch({ @@ -217,8 +217,8 @@ const performUndoableQuery = ({ dispatch({ type: FETCH_ERROR, error }); onFailure && onFailure(error); throw new Error(error.message ? error.message : error); - }); - }); + }) + ); }); return Promise.resolve({}); }; @@ -273,7 +273,7 @@ const performQuery = ({ onSuccess && onSuccess(response); return response; }) - .catch(error => { + .catch(error => logoutIfAccessDenied(error).then(loggedOut => { if (loggedOut) return; dispatch({ @@ -291,8 +291,8 @@ const performQuery = ({ dispatch({ type: FETCH_ERROR, error }); onFailure && onFailure(error); throw new Error(error.message ? error.message : error); - }); - }); + }) + ); }; interface QueryFunctionParams { From 94dfbe43e433c53f59f1471df624504a3a7e0dfd Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Mon, 9 Sep 2019 10:20:59 +0200 Subject: [PATCH 21/26] Fix e2e test --- cypress/integration/auth.js | 2 +- packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cypress/integration/auth.js b/cypress/integration/auth.js index c6559482ca7..c45630c3165 100644 --- a/cypress/integration/auth.js +++ b/cypress/integration/auth.js @@ -15,7 +15,7 @@ describe('Authentication', () => { ListPage.navigate(); ListPage.logout(); ListPage.navigate(); - cy.url().then(url => expect(url).to.contain('/#/login')); + cy.url().should('contain', '/#/login'); }); it('should not login with incorrect credentials', () => { LoginPage.navigate(); diff --git a/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx b/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx index ae3e94ca4dd..1ba796d8e6d 100644 --- a/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx +++ b/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx @@ -27,7 +27,7 @@ const TestComponent = ({ error }: { error?: any }) => { const authProvider = (type, params) => new Promise((resolve, reject) => { if (type !== 'AUTH_ERROR') reject('bad method'); - if (params instanceof Error && params.message == 'denied') { + if (params instanceof Error && params.message === 'denied') { reject(new Error('logout')); } resolve(); From 9730e6c72a171a4d92ccb562e26c8a24394cec08 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Mon, 9 Sep 2019 13:54:15 +0200 Subject: [PATCH 22/26] [BC Break] Do not pass location to AUTH_GET_PERMISSIONS call Also, hooks that return a callback no longer accept a parameter. Pass the parameter to the callback instead. --- UPGRADE.md | 25 +++++++++++ packages/ra-core/src/auth/useAuthState.ts | 10 ++--- .../src/auth/useAuthenticated.spec.tsx | 3 -- .../ra-core/src/auth/useAuthenticated.tsx | 10 +++-- packages/ra-core/src/auth/useCheckAuth.ts | 24 +++++----- .../ra-core/src/auth/useGetPermissions.ts | 35 +++------------ packages/ra-core/src/auth/useLogin.ts | 13 +++--- packages/ra-core/src/auth/useLogout.ts | 44 +++++++++---------- packages/ra-core/src/auth/usePermissions.ts | 10 ++--- 9 files changed, 88 insertions(+), 86 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index dc4e0a1a7d6..bcd89a0284c 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -965,3 +965,28 @@ const ExportButton = ({ sort, filter, maxResults = 1000, resource }) => { ); }; ``` + +## The `authProvider` no longer receives the Location pathname in AUTH_GET_PERMISSIONS + +When calling the `authProvider` for permissions (with the `AUTH_GET_PERMISSIONS` verb), react-admin used to include the pathname as second parameter. That allowed you to return different permissions based on the page. + +We believe that permissions should not vary depending on where you are in the application ; it's up to components to decide to do something or not depending on permissions. So we've removed the pathname parameter from the calls - the `authProvider` doesn't receive it anymore. + +If you want to keep location-dependent permissions logic, red the current location from the `window` object direclty in your `authProvider`: + +```diff +// in myauthProvider.js +import { AUTH_LOGIN, AUTH_LOGOUT, AUTH_ERROR, AUTH_GET_PERMISSIONS } from 'react-admin'; +import decodeJwt from 'jwt-decode'; + +export default (type, params) => { + // ... + if (type === AUTH_GET_PERMISSIONS) { +- const { pathname } = params; ++ const pathname = window.location.pathname; + // pathname-dependent logic follows + // ... + } + return Promise.reject('Unknown method'); +}; +``` diff --git a/packages/ra-core/src/auth/useAuthState.ts b/packages/ra-core/src/auth/useAuthState.ts index 115113a4725..e83509b5909 100644 --- a/packages/ra-core/src/auth/useAuthState.ts +++ b/packages/ra-core/src/auth/useAuthState.ts @@ -29,7 +29,7 @@ const emptyParams = {}; * * @see useAuthenticated() * - * @param {Object} authParams Any params you want to pass to the authProvider + * @param {Object} params Any params you want to pass to the authProvider * * @returns The current auth check state. Destructure as { authenticated, error, loading, loaded }. * @@ -48,22 +48,22 @@ const emptyParams = {}; * * ); */ -const useAuthState = (authParams: any = emptyParams): State => { +const useAuthState = (params: any = emptyParams): State => { const [state, setState] = useSafeSetState({ loading: true, loaded: false, authenticated: true, // optimistic }); - const checkAuth = useCheckAuth(authParams); + const checkAuth = useCheckAuth(); useEffect(() => { - checkAuth(false) + checkAuth(params, false) .then(() => setState({ loading: false, loaded: true, authenticated: true }) ) .catch(() => setState({ loading: false, loaded: true, authenticated: false }) ); - }, [checkAuth, setState]); + }, [checkAuth, params, setState]); return state; }; diff --git a/packages/ra-core/src/auth/useAuthenticated.spec.tsx b/packages/ra-core/src/auth/useAuthenticated.spec.tsx index afabb44bb0f..d166010d8ef 100644 --- a/packages/ra-core/src/auth/useAuthenticated.spec.tsx +++ b/packages/ra-core/src/auth/useAuthenticated.spec.tsx @@ -24,9 +24,6 @@ describe('useAuthenticated', () => { ); expect(authProvider).toBeCalledTimes(1); expect(authProvider.mock.calls[0][0]).toBe('AUTH_CHECK'); - const payload = authProvider.mock.calls[0][1] as any; - expect(payload.afterLoginUrl).toBe('/'); - expect(payload.loginUrl).toBe('/login'); expect(dispatch).toHaveBeenCalledTimes(0); }); diff --git a/packages/ra-core/src/auth/useAuthenticated.tsx b/packages/ra-core/src/auth/useAuthenticated.tsx index 4f57ac6865d..dd6f781acb8 100644 --- a/packages/ra-core/src/auth/useAuthenticated.tsx +++ b/packages/ra-core/src/auth/useAuthenticated.tsx @@ -1,6 +1,8 @@ import { useEffect } from 'react'; import useCheckAuth from './useCheckAuth'; +const emptyParams = {}; + /** * Restrict access to authenticated users. * Redirect anonymous users to the login page. @@ -26,9 +28,9 @@ import useCheckAuth from './useCheckAuth'; * * ); */ -export default authParams => { - const checkAuth = useCheckAuth(authParams); +export default (params: any = emptyParams) => { + const checkAuth = useCheckAuth(); useEffect(() => { - checkAuth().catch(() => {}); - }, [checkAuth]); + checkAuth(params).catch(() => {}); + }, [checkAuth, params]); }; diff --git a/packages/ra-core/src/auth/useCheckAuth.ts b/packages/ra-core/src/auth/useCheckAuth.ts index 7263900a876..ea20f87b505 100644 --- a/packages/ra-core/src/auth/useCheckAuth.ts +++ b/packages/ra-core/src/auth/useCheckAuth.ts @@ -16,8 +16,6 @@ import useNotify from '../sideEffect/useNotify'; * @see useAuthenticated * @see useAuthState * - * @param {Object} authParams Any params you want to pass to the authProvider - * * @returns {Function} checkAuth callback * * @example @@ -33,24 +31,28 @@ import useNotify from '../sideEffect/useNotify'; * } // tip: use useAuthenticated() hook instead * * const MyPage = () => { - * const checkAuth = usecheckAuth(); + * const checkAuth = useCheckAuth(); * const [authenticated, setAuthenticated] = useState(true); // optimistic auth * useEffect(() => { - * checkAuth(false) + * checkAuth({}, false) * .then() => setAuthenticated(true)) * .catch(() => setAuthenticated(false)); * }, []); * return authenticated ? : ; * } // tip: use useAuthState() hook instead */ -const useCheckAuth = (authParams: any = defaultAuthParams): CheckAuth => { +const useCheckAuth = (): CheckAuth => { const authProvider = useAuthProvider(); const notify = useNotify(); - const logout = useLogout(authParams); + const logout = useLogout(); const checkAuth = useCallback( - (logoutOnFailure = true, redirectTo = authParams.loginUrl) => - authProvider(AUTH_CHECK, authParams).catch(error => { + ( + params: any = {}, + logoutOnFailure = true, + redirectTo = defaultAuthParams.loginUrl + ) => + authProvider(AUTH_CHECK, params).catch(error => { if (logoutOnFailure) { logout(redirectTo); notify( @@ -60,24 +62,26 @@ const useCheckAuth = (authParams: any = defaultAuthParams): CheckAuth => { } throw error; }), - [authParams, authProvider, logout, notify] + [authProvider, logout, notify] ); return authProvider ? checkAuth : checkAuthWithoutAuthProvider; }; -const checkAuthWithoutAuthProvider = (_, __) => Promise.resolve(); +const checkAuthWithoutAuthProvider = () => Promise.resolve(); /** * Check if the current user is authenticated by calling the authProvider AUTH_CHECK verb. * Logs the user out on failure. * + * @param {Object} params The parameters 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 {string} redirectTo The login form url. Defaults to '/login' * * @return {Promise} Resolved to the authProvider response if the user passes the check, or rejected with an error otherwise */ type CheckAuth = ( + params?: any, logoutOnFailure?: boolean, redirectTo?: string ) => Promise; diff --git a/packages/ra-core/src/auth/useGetPermissions.ts b/packages/ra-core/src/auth/useGetPermissions.ts index 7512cd25c97..1070df66c22 100644 --- a/packages/ra-core/src/auth/useGetPermissions.ts +++ b/packages/ra-core/src/auth/useGetPermissions.ts @@ -1,15 +1,12 @@ import { useCallback } from 'react'; -import { useStore } from 'react-redux'; -import { Location } from 'history'; -import useAuthProvider, { defaultAuthParams } from './useAuthProvider'; +import useAuthProvider from './useAuthProvider'; import { AUTH_GET_PERMISSIONS } from './types'; /** * Get a callback for calling the authProvider with the AUTH_GET_PERMISSIONS verb. * * @see useAuthProvider - * @param {Object} authParams Any params you want to pass to the authProvider * * @returns {Function} getPermissions callback * @@ -37,31 +34,11 @@ import { AUTH_GET_PERMISSIONS } from './types'; * ); * } */ -const useGetPermissions = ( - authParams: any = defaultAuthParams -): GetPermissions => { +const useGetPermissions = (): GetPermissions => { const authProvider = useAuthProvider(); - /** - * We need the current location to pass to the authProvider for GET_PERMISSIONS. - * - * But if we used useSelector to read it from the store, the getPermissions function - * would be rebuilt each time the user changes location. Consequently, that - * would force a rerender of the enclosing component upon navigation. - * - * To avoid that, we don't subscribe to the store using useSelector; - * instead, we get a pointer to the store, and determine the location only - * after the getPermissions function was called. - */ - - const store = useStore(); - const getPermissions = useCallback( - (location?: Location) => - authProvider(AUTH_GET_PERMISSIONS, { - location: location || store.getState().router.location, - ...authParams, - }), - [authParams, authProvider, store] + (params: any = {}) => authProvider(AUTH_GET_PERMISSIONS, params), + [authProvider] ); return authProvider ? getPermissions : getPermissionsWithoutProvider; @@ -72,10 +49,10 @@ const getPermissionsWithoutProvider = () => Promise.resolve([]); /** * Ask the permissions to the authProvider using the AUTH_GET_PERMISSIONS verb * - * @param {Location} location the current location from history (optional) + * @param {Object} params The parameters to pass to the authProvider * * @return {Promise} The authProvider response */ -type GetPermissions = (location?: Location) => Promise; +type GetPermissions = (params?: any) => Promise; export default useGetPermissions; diff --git a/packages/ra-core/src/auth/useLogin.ts b/packages/ra-core/src/auth/useLogin.ts index b4296ae035f..d0d907eca6f 100644 --- a/packages/ra-core/src/auth/useLogin.ts +++ b/packages/ra-core/src/auth/useLogin.ts @@ -11,7 +11,6 @@ import { ReduxState } from '../types'; * and redirect to the previous authenticated page (or the home page) on success. * * @see useAuthProvider - * @param {Object} authParams Any params you want to pass to the authProvider * * @returns {Function} login callback * @@ -30,7 +29,7 @@ import { ReduxState } from '../types'; * return ; * } */ -const useLogin = (authParams: any = defaultAuthParams): Login => { +const useLogin = (): Login => { const authProvider = useAuthProvider(); const currentLocation = useSelector( (state: ReduxState) => state.router.location @@ -40,20 +39,20 @@ const useLogin = (authParams: any = defaultAuthParams): Login => { const dispatch = useDispatch(); const login = useCallback( - (params, pathName = authParams.afterLoginUrl) => - authProvider(AUTH_LOGIN, { ...params, ...authParams }).then(ret => { + (params: any = {}, pathName = defaultAuthParams.afterLoginUrl) => + authProvider(AUTH_LOGIN, params).then(ret => { dispatch(push(nextPathName || pathName)); return ret; }), - [authParams, authProvider, dispatch, nextPathName] + [authProvider, dispatch, nextPathName] ); const loginWithoutProvider = useCallback( (_, __) => { - dispatch(push(authParams.afterLoginUrl)); + dispatch(push(defaultAuthParams.afterLoginUrl)); return Promise.resolve(); }, - [authParams.afterLoginUrl, dispatch] + [dispatch] ); return authProvider ? login : loginWithoutProvider; diff --git a/packages/ra-core/src/auth/useLogout.ts b/packages/ra-core/src/auth/useLogout.ts index 1e32e9215ad..71f6cd34bf1 100644 --- a/packages/ra-core/src/auth/useLogout.ts +++ b/packages/ra-core/src/auth/useLogout.ts @@ -11,7 +11,6 @@ import { clearState } from '../actions/clearActions'; * redirect to the login page, and clear the Redux state. * * @see useAuthProvider - * @param {Object} authParams Any params you want to pass to the authProvider * * @returns {Function} logout callback * @@ -25,7 +24,7 @@ import { clearState } from '../actions/clearActions'; * return ; * } */ -const useLogout = (authParams: any = defaultAuthParams): Logout => { +const useLogout = (): Logout => { const authProvider = useAuthProvider(); /** * We need the current location to pass in the router state @@ -44,24 +43,22 @@ const useLogout = (authParams: any = defaultAuthParams): Logout => { const dispatch = useDispatch(); const logout = useCallback( - (redirectTo = authParams.loginUrl) => - authProvider(AUTH_LOGOUT, authParams).then( - redirectToFromProvider => { - dispatch(clearState()); - const currentLocation = store.getState().router.location; - dispatch( - push({ - pathname: redirectToFromProvider || redirectTo, - state: { - nextPathname: - currentLocation && currentLocation.pathname, - }, - }) - ); - return redirectToFromProvider; - } - ), - [authParams, authProvider, store, dispatch] + (params = {}, redirectTo = defaultAuthParams.loginUrl) => + authProvider(AUTH_LOGOUT, params).then(redirectToFromProvider => { + dispatch(clearState()); + const currentLocation = store.getState().router.location; + dispatch( + push({ + pathname: redirectToFromProvider || redirectTo, + state: { + nextPathname: + currentLocation && currentLocation.pathname, + }, + }) + ); + return redirectToFromProvider; + }), + [authProvider, store, dispatch] ); const logoutWithoutProvider = useCallback( @@ -69,7 +66,7 @@ const useLogout = (authParams: any = defaultAuthParams): Logout => { const currentLocation = store.getState().router.location; dispatch( push({ - pathname: authParams.loginUrl, + pathname: defaultAuthParams.loginUrl, state: { nextPathname: currentLocation && currentLocation.pathname, @@ -79,7 +76,7 @@ const useLogout = (authParams: any = defaultAuthParams): Logout => { dispatch(clearState()); return Promise.resolve(); }, - [authParams.loginUrl, store, dispatch] + [store, dispatch] ); return authProvider ? logout : logoutWithoutProvider; @@ -89,10 +86,11 @@ const useLogout = (authParams: any = defaultAuthParams): Logout => { * Log the current user out by calling the authProvider AUTH_LOGOUT verb, * and redirect them to the login screen. * + * @param {Object} params The parameters to pass to the authProvider * @param {string} redirectTo The path name to redirect the user to (optional, defaults to login) * * @return {Promise} The authProvider response */ -type Logout = (redirectTo?: string) => Promise; +type Logout = (params?: any, redirectTo?: string) => Promise; export default useLogout; diff --git a/packages/ra-core/src/auth/usePermissions.ts b/packages/ra-core/src/auth/usePermissions.ts index 14705c01f74..00253bad030 100644 --- a/packages/ra-core/src/auth/usePermissions.ts +++ b/packages/ra-core/src/auth/usePermissions.ts @@ -26,7 +26,7 @@ const emptyParams = {}; * * Useful to enable features based on user permissions * - * @param {Object} authParams Any params you want to pass to the authProvider + * @param {Object} params Any params you want to pass to the authProvider * * @returns The current auth check state. Destructure as { permissions, error, loading, loaded }. * @@ -42,14 +42,14 @@ const emptyParams = {}; * } * }; */ -const usePermissions = (authParams = emptyParams) => { +const usePermissions = (params = emptyParams) => { const [state, setState] = useSafeSetState({ loading: true, loaded: false, }); - const getPermissions = useGetPermissions(authParams); + const getPermissions = useGetPermissions(); useEffect(() => { - getPermissions() + getPermissions(params) .then(permissions => { setState({ loading: false, loaded: true, permissions }); }) @@ -60,7 +60,7 @@ const usePermissions = (authParams = emptyParams) => { error, }); }); - }, [authParams, getPermissions, setState]); + }, [getPermissions, params, setState]); return state; }; From 49abe21ae10343dae672495ca50070bee0efeeda Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Mon, 9 Sep 2019 15:38:15 +0200 Subject: [PATCH 23/26] Allow authProvider to override redirection url in AUTH_CHECK and AUTH_ERROR --- UPGRADE.md | 16 ++++++---- docs/Authentication.md | 29 ------------------- packages/ra-core/src/auth/useCheckAuth.ts | 7 ++++- .../src/auth/useLogoutIfAccessDenied.ts | 8 ++++- 4 files changed, 23 insertions(+), 37 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index bcd89a0284c..e39afa6dc75 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -966,13 +966,13 @@ const ExportButton = ({ sort, filter, maxResults = 1000, resource }) => { }; ``` -## The `authProvider` no longer receives the Location pathname in AUTH_GET_PERMISSIONS +## The `authProvider` no longer receives default parameters -When calling the `authProvider` for permissions (with the `AUTH_GET_PERMISSIONS` verb), react-admin used to include the pathname as second parameter. That allowed you to return different permissions based on the page. +When calling the `authProvider` for permissions (with the `AUTH_GET_PERMISSIONS` verb), react-admin used to include the pathname as second parameter. That allowed you to return different permissions based on the page. In a similar fashion, for the `AUTH_CHECK` call, the `params` argument contained the `resource` name, allowing different checks for different resources. -We believe that permissions should not vary depending on where you are in the application ; it's up to components to decide to do something or not depending on permissions. So we've removed the pathname parameter from the calls - the `authProvider` doesn't receive it anymore. +We believe that authentication and permissions should not vary depending on where you are in the application ; it's up to components to decide to do something or not depending on permissions. So we've removed the default parameters from all the `authProvider` calls. -If you want to keep location-dependent permissions logic, red the current location from the `window` object direclty in your `authProvider`: +If you want to keep location-dependent authentication or permissions logic, read the current location from the `window` object direclty in your `authProvider`, using `window.location.hash` (if you use a hash router), or using `window.location.pathname` (if you use a browser router): ```diff // in myauthProvider.js @@ -980,10 +980,14 @@ import { AUTH_LOGIN, AUTH_LOGOUT, AUTH_ERROR, AUTH_GET_PERMISSIONS } from 'react import decodeJwt from 'jwt-decode'; export default (type, params) => { - // ... + if (type === AUTH_CHECK) { +- const { resource } = params; ++ const resource = window.location.hash.substring(2, window.location.hash.indexOf('/', 2)) + // resource-dependent logic follows + } if (type === AUTH_GET_PERMISSIONS) { - const { pathname } = params; -+ const pathname = window.location.pathname; ++ const pathname = window.location.hash; // pathname-dependent logic follows // ... } diff --git a/docs/Authentication.md b/docs/Authentication.md index c60aa71ff06..3e4b9b93016 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -228,35 +228,6 @@ export default (type, params) => { Note that react-admin will call the `authProvider` with the `AUTH_LOGOUT` type before redirecting. If you specify the `redirectTo` here, it will override the url which may have been return by the call to `AUTH_LOGOUT`. -**Tip**: For the `AUTH_CHECK` call, the `params` argument contains the `resource` name, so you can implement different checks for different resources: - -```jsx -// in src/authProvider.js -import { AUTH_LOGIN, AUTH_LOGOUT, AUTH_ERROR, AUTH_CHECK } from 'react-admin'; - -export default (type, params) => { - if (type === AUTH_LOGIN) { - // ... - } - if (type === AUTH_LOGOUT) { - // ... - } - if (type === AUTH_ERROR) { - // ... - } - if (type === AUTH_CHECK) { - const { resource } = params; - if (resource === 'posts') { - // check credentials for the posts resource - } - if (resource === 'comments') { - // check credentials for the comments resource - } - } - return Promise.resolve(); -}; -``` - **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 diff --git a/packages/ra-core/src/auth/useCheckAuth.ts b/packages/ra-core/src/auth/useCheckAuth.ts index ea20f87b505..a9a9a3b9b2e 100644 --- a/packages/ra-core/src/auth/useCheckAuth.ts +++ b/packages/ra-core/src/auth/useCheckAuth.ts @@ -54,7 +54,12 @@ const useCheckAuth = (): CheckAuth => { ) => authProvider(AUTH_CHECK, params).catch(error => { if (logoutOnFailure) { - logout(redirectTo); + logout( + {}, + error && error.redirectTo + ? error.redirectTo + : redirectTo + ); notify( getErrorMessage(error, 'ra.auth.auth_check_error'), 'warning' diff --git a/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts b/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts index 4efe1a47480..d14c95cc351 100644 --- a/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts +++ b/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts @@ -45,7 +45,13 @@ const useLogoutIfAccessDenied = (): LogoutIfAccessDenied => { authProvider(AUTH_ERROR, error) .then(() => false) .catch(e => { - logout(); + const redirectTo = + e && e.redirectTo + ? e.redirectTo + : error && error.redirectTo + ? error.redirectto + : undefined; + logout({}, redirectTo); notify('ra.notification.logged_out', 'warning'); return true; }), From 080de6a2ec0d45431d87442f00356e5cdaffba1f Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Mon, 9 Sep 2019 15:48:15 +0200 Subject: [PATCH 24/26] Redirect to homepage when visiting the login page while connected --- packages/ra-ui-materialui/src/auth/Login.tsx | 25 +++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/ra-ui-materialui/src/auth/Login.tsx b/packages/ra-ui-materialui/src/auth/Login.tsx index 8bb89beaa37..4d7aa2ade25 100644 --- a/packages/ra-ui-materialui/src/auth/Login.tsx +++ b/packages/ra-ui-materialui/src/auth/Login.tsx @@ -7,12 +7,19 @@ import React, { } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; -import Card from '@material-ui/core/Card'; -import Avatar from '@material-ui/core/Avatar'; -import { createMuiTheme, makeStyles, Theme } from '@material-ui/core/styles'; +import { + Card, + Avatar, + createMuiTheme, + makeStyles, + Theme, +} from '@material-ui/core'; import { ThemeProvider } from '@material-ui/styles'; import LockIcon from '@material-ui/icons/Lock'; import { StaticContext } from 'react-router'; +import { push } from 'connected-react-router'; +import { useDispatch } from 'react-redux'; +import { useCheckAuth } from 'ra-core'; import defaultTheme from '../defaultTheme'; import Notification from '../layout/Notification'; @@ -82,6 +89,18 @@ const Login: React.FunctionComponent< const styles = useStyles({}); const muiTheme = useMemo(() => createMuiTheme(theme), [theme]); let backgroundImageLoaded = false; + const checkAuth = useCheckAuth(); + const dispatch = useDispatch(); + useEffect(() => { + checkAuth() + .then(() => { + // already authenticated, redirect to the home page + dispatch(push('/')); + }) + .catch(() => { + // not authenticated, stay on the login page + }); + }, [checkAuth, dispatch]); const updateBackgroundImage = () => { if (!backgroundImageLoaded && containerRef.current) { From cdd94eab42226214ec36cbf9215ce7b0dee9eb4c Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Mon, 9 Sep 2019 15:57:17 +0200 Subject: [PATCH 25/26] Mention no more auth actions in upgrade guide --- UPGRADE.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/UPGRADE.md b/UPGRADE.md index e39afa6dc75..0d8e0732e01 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -994,3 +994,21 @@ export default (type, params) => { return Promise.reject('Unknown method'); }; ``` + +## No more Redux actions for authentication + +React-admin now uses hooks instead of sagas to handle authentication and authorization. That means that react-admin no longer dispatches the following actions: + +- `USER_LOGIN` +- `USER_LOGIN_LOADING` +- `USER_LOGIN_FAILURE` +- `USER_LOGIN_SUCCESS` +- `USER_CHECK` +- `USER_CHECK_SUCCESS` +- `USER_LOGOUT` + +If you have custom Login or Logout buttons that dispatch these actions, they will still work, but you are encouraged to migrate to the hook equivalents (`useLogin` and `useLogout`). + +If you had custom reducer or sagas based on these actions, they will no longer work. You will have to reimplement that custom logic using the new authentication hooks. + +**Tip**: If you need to clear the Redux state, you can dispatch the `CLEAR_STATE` action. From d079b11f9aaabd7c61e927cbc61cb94fbde5bf42 Mon Sep 17 00:00:00 2001 From: fzaninotto Date: Mon, 9 Sep 2019 18:55:24 +0200 Subject: [PATCH 26/26] Fix e2e tests --- cypress/integration/auth.js | 6 ++++-- cypress/integration/create.js | 12 +++++++++--- cypress/integration/list.js | 6 +++--- cypress/integration/permissions.js | 19 ++++++++++++------- cypress/integration/tabs-with-routing.js | 5 +++-- cypress/support/CreatePage.js | 9 ++++++++- cypress/support/ShowPage.js | 9 ++++++++- 7 files changed, 47 insertions(+), 19 deletions(-) diff --git a/cypress/integration/auth.js b/cypress/integration/auth.js index c45630c3165..7f54325a4f0 100644 --- a/cypress/integration/auth.js +++ b/cypress/integration/auth.js @@ -18,12 +18,14 @@ describe('Authentication', () => { cy.url().should('contain', '/#/login'); }); it('should not login with incorrect credentials', () => { - LoginPage.navigate(); + ListPage.navigate(); + ListPage.logout(); LoginPage.login('foo', 'bar'); cy.contains('Authentication failed, please retry'); }); it('should login with correct credentials', () => { - LoginPage.navigate(); + ListPage.navigate(); + ListPage.logout(); LoginPage.login('login', 'password'); cy.url().then(url => expect(url).to.contain('/#/posts')); }); diff --git a/cypress/integration/create.js b/cypress/integration/create.js index f61c6b38e38..11948215c1f 100644 --- a/cypress/integration/create.js +++ b/cypress/integration/create.js @@ -11,9 +11,8 @@ describe('Create Page', () => { const LoginPage = loginPageFactory('/#/login'); beforeEach(() => { - LoginPage.navigate(); - LoginPage.login('admin', 'password'); CreatePage.navigate(); + CreatePage.waitUntilVisible(); }); it('should show the correct title in the appBar', () => { @@ -40,6 +39,9 @@ describe('Create Page', () => { }); it('should have a working array input with references', () => { + CreatePage.logout(); + LoginPage.login('admin', 'password'); + CreatePage.waitUntilVisible(); cy.get(CreatePage.elements.addAuthor).click(); cy.get(CreatePage.elements.input('authors[0].user_id')).should( el => expect(el).to.exist @@ -50,6 +52,9 @@ describe('Create Page', () => { }); it('should have a working array input with a scoped FormDataConsumer', () => { + CreatePage.logout(); + LoginPage.login('admin', 'password'); + CreatePage.waitUntilVisible(); cy.get(CreatePage.elements.addAuthor).click(); CreatePage.setValues([ { @@ -215,8 +220,9 @@ describe('Create Page', () => { }); it('should not reset the form value when switching tabs', () => { - LoginPage.navigate(); + CreatePage.logout(); LoginPage.login('admin', 'password'); + CreatePage.waitUntilVisible(); UserCreatePage.navigate(); CreatePage.setValues([ diff --git a/cypress/integration/list.js b/cypress/integration/list.js index fec39761f4c..f93685889f1 100644 --- a/cypress/integration/list.js +++ b/cypress/integration/list.js @@ -75,7 +75,7 @@ describe('List Page', () => { }); it('should keep filters when navigating away and going back on given page', () => { - LoginPage.navigate(); + ListPagePosts.logout(); LoginPage.login('admin', 'password'); ListPagePosts.setFilterValue('q', 'quis culpa impedit'); cy.contains('1-1 of 1'); @@ -96,7 +96,7 @@ describe('List Page', () => { }); it('should allow to disable alwaysOn filters with default value', () => { - LoginPage.navigate(); + ListPagePosts.logout(); LoginPage.login('admin', 'password'); ListPageUsers.navigate(); cy.contains('1-2 of 2'); @@ -181,7 +181,7 @@ describe('List Page', () => { }); it('should accept a function returning a promise', () => { - LoginPage.navigate(); + ListPagePosts.logout(); LoginPage.login('user', 'password'); ListPageUsers.navigate(); cy.contains('Annamarie Mayer') diff --git a/cypress/integration/permissions.js b/cypress/integration/permissions.js index 5d97f92ed00..f912ee300e6 100644 --- a/cypress/integration/permissions.js +++ b/cypress/integration/permissions.js @@ -9,11 +9,13 @@ describe('Permissions', () => { const EditPage = editPageFactory('/#/users/1'); const ListPage = listPageFactory('/#/users'); const LoginPage = loginPageFactory('/#/login'); - const ShowPage = showPageFactory('/#/users/1/show', 'name'); + const ShowPage = showPageFactory('/#/posts/1/show', 'title'); + const UserShowPage = showPageFactory('/#/users/1/show', 'name'); describe('Resources', () => { it('hides protected resources depending on permissions', () => { - LoginPage.navigate(); + ShowPage.navigate(); + ShowPage.logout(); LoginPage.login('login', 'password'); cy.contains('Posts'); cy.contains('Comments'); @@ -21,7 +23,8 @@ describe('Permissions', () => { }); it('shows protected resources depending on permissions', () => { - LoginPage.navigate(); + ShowPage.navigate(); + ShowPage.logout(); LoginPage.login('user', 'password'); cy.contains('Posts'); cy.contains('Comments'); @@ -31,9 +34,10 @@ describe('Permissions', () => { describe('hides protected data depending on permissions', () => { beforeEach(() => { + ShowPage.navigate(); + ShowPage.logout(); LoginPage.navigate(); LoginPage.login('user', 'password'); - cy.url().then(url => expect(url).to.contain('/#/posts')); }); it('in List page with DataGrid', () => { @@ -79,9 +83,10 @@ describe('Permissions', () => { describe('shows protected data depending on permissions', () => { beforeEach(() => { + ShowPage.navigate(); + ShowPage.logout(); LoginPage.navigate(); LoginPage.login('admin', 'password'); - cy.url().then(url => expect(url).to.contain('/#/posts')); }); it('in List page with DataGrid', () => { @@ -109,12 +114,12 @@ describe('Permissions', () => { }); it('in Show page', () => { - ShowPage.navigate(); + UserShowPage.navigate(); cy.contains('Id'); cy.contains('Name'); cy.contains('Summary'); cy.contains('Security'); - ShowPage.gotoTab(2); + UserShowPage.gotoTab(2); cy.contains('Role'); }); diff --git a/cypress/integration/tabs-with-routing.js b/cypress/integration/tabs-with-routing.js index 959bfc53b52..3df973155e2 100644 --- a/cypress/integration/tabs-with-routing.js +++ b/cypress/integration/tabs-with-routing.js @@ -8,9 +8,10 @@ describe('Tabs with routing', () => { const LoginPage = loginPageFactory('#/login'); beforeEach(() => { - LoginPage.navigate(); + ShowPage.navigate(); + ShowPage.logout(); LoginPage.login('admin', 'password'); - cy.url().then(url => expect(url).to.contain('#/posts')); + cy.url().then(url => expect(url).to.contain('#/users')); }); describe('in TabbedLayout component', () => { diff --git a/cypress/support/CreatePage.js b/cypress/support/CreatePage.js index db491162bf9..554e8b23f87 100644 --- a/cypress/support/CreatePage.js +++ b/cypress/support/CreatePage.js @@ -21,6 +21,8 @@ export default url => ({ descInput: '.ql-editor', tab: index => `.form-tab:nth-of-type(${index})`, title: '#react-admin-title', + userMenu: 'button[title="Profile"]', + logout: '.logout', }, navigate() { @@ -28,7 +30,7 @@ export default url => ({ }, waitUntilVisible() { - cy.get(this.elements.submitButton); + cy.get(this.elements.submitButton).should('be.visible'); }, setInputValue(type, name, value, clearPreviousValue = true) { @@ -89,4 +91,9 @@ export default url => ({ gotoTab(index) { cy.get(this.elements.tab(index)).click(); }, + + logout() { + cy.get(this.elements.userMenu).click(); + cy.get(this.elements.logout).click(); + }, }); diff --git a/cypress/support/ShowPage.js b/cypress/support/ShowPage.js index 854b0d9f46e..7759eaad4e0 100644 --- a/cypress/support/ShowPage.js +++ b/cypress/support/ShowPage.js @@ -6,6 +6,8 @@ export default (url, initialField = 'title') => ({ snackbar: 'div[role="alertdialog"]', tabs: `.show-tab`, tab: index => `.show-tab:nth-of-type(${index})`, + userMenu: 'button[title="Profile"]', + logout: '.logout', }, navigate() { @@ -13,10 +15,15 @@ export default (url, initialField = 'title') => ({ }, waitUntilVisible() { - cy.get(this.elements.field(initialField)); + cy.get(this.elements.field(initialField)).should('be.visible'); }, gotoTab(index) { cy.get(this.elements.tab(index)).click(); }, + + logout() { + cy.get(this.elements.userMenu).click(); + cy.get(this.elements.logout).click(); + }, });