diff --git a/docs/Authentication.md b/docs/Authentication.md index af92d28116e..377bf15f692 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -186,6 +186,25 @@ export default { }; ``` +It's possible to not log the user out, and to instead redirect them. You can do this by passing `error.logoutUser = false` to the `Promise.reject` along with an `error.redirectTo` url. + + +```js +// in src/authProvider.js +export default { + login: ({ username, password }) => { /* ... */ }, + checkError: (error) => { + const status = error.status; + if (status === 401 || status === 403) { + return Promise.reject({ redirectTo: '/unauthorized', logoutUser: false }); + } + // other error code (404, 500, etc): no need to log out + return Promise.resolve(); + }, + // ... +}; +``` + When `authProvider.checkError()` returns a rejected Promise, react-admin displays a notification to the end user, unless the `error.message` is `false`. That means you can disable the notification on error as follows: ```js diff --git a/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx b/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx index 474ca9853e6..9a04b224db0 100644 --- a/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx +++ b/packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx @@ -2,6 +2,8 @@ import * as React from 'react'; import { useState, useEffect } from 'react'; import expect from 'expect'; import { render, waitFor } from '@testing-library/react'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; import useLogoutIfAccessDenied from './useLogoutIfAccessDenied'; import AuthContext from './AuthContext'; @@ -58,6 +60,16 @@ const notify = jest.fn(); //@ts-expect-error useNotify.mockImplementation(() => notify); +function renderInRouter(children) { + const history = createMemoryHistory(); + const api = render({children}); + + return { + ...api, + history, + }; +} + describe('useLogoutIfAccessDenied', () => { afterEach(() => { //@ts-expect-error @@ -67,11 +79,12 @@ describe('useLogoutIfAccessDenied', () => { }); it('should not logout if passed no error', async () => { - const { queryByText } = render( + const { queryByText } = renderInRouter( ); + await waitFor(() => { expect(authProvider.logout).toHaveBeenCalledTimes(0); expect(notify).toHaveBeenCalledTimes(0); @@ -80,7 +93,7 @@ describe('useLogoutIfAccessDenied', () => { }); it('should not log out if passed an error that does not make the authProvider throw', async () => { - const { queryByText } = render( + const { queryByText } = renderInRouter( @@ -93,7 +106,7 @@ describe('useLogoutIfAccessDenied', () => { }); it('should logout if passed an error that makes the authProvider throw', async () => { - const { queryByText } = render( + const { queryByText } = renderInRouter( @@ -106,7 +119,7 @@ describe('useLogoutIfAccessDenied', () => { }); it('should not send multiple notifications if already logged out', async () => { - const { queryByText } = render( + const { queryByText } = renderInRouter( @@ -129,7 +142,7 @@ describe('useLogoutIfAccessDenied', () => { index++; // answers immediately first, then after 100ms the second time }), }; - const { queryByText } = render( + const { queryByText } = renderInRouter( @@ -143,7 +156,7 @@ describe('useLogoutIfAccessDenied', () => { }); it('should logout without showing a notification if disableAuthentication is true', async () => { - const { queryByText } = render( + const { queryByText } = renderInRouter( { }); it('should logout without showing a notification if authProvider returns error with message false', async () => { - const { queryByText } = render( + const { queryByText } = renderInRouter( { expect(queryByText('logged in')).toBeNull(); }); }); + + it('should not logout the user if logoutUser is set to false', async () => { + const { queryByText, history } = renderInRouter( + { + return Promise.reject({ + logoutUser: false, + redirectTo: '/unauthorized', + }); + }, + }} + > + + + ); + await waitFor(() => { + expect(authProvider.logout).toHaveBeenCalledTimes(0); + expect(notify).toHaveBeenCalledTimes(1); + expect(queryByText('logged in')).toBeNull(); + expect(history.location.pathname).toBe('/unauthorized'); + }); + }); }); diff --git a/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts b/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts index c1652812e97..22b84ae11ea 100644 --- a/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts +++ b/packages/ra-core/src/auth/useLogoutIfAccessDenied.ts @@ -3,6 +3,7 @@ import { useCallback } from 'react'; import useAuthProvider from './useAuthProvider'; import useLogout from './useLogout'; import { useNotify } from '../sideEffect'; +import { useHistory } from 'react-router'; let timer; @@ -41,12 +42,15 @@ const useLogoutIfAccessDenied = (): LogoutIfAccessDenied => { const authProvider = useAuthProvider(); const logout = useLogout(); const notify = useNotify(); + const history = useHistory(); const logoutIfAccessDenied = useCallback( (error?: any, disableNotification?: boolean) => authProvider .checkError(error) .then(() => false) .catch(async e => { + const logoutUser = e?.logoutUser ?? true; + //manual debounce if (timer) { // side effects already triggered in this tick, exit @@ -66,7 +70,17 @@ const useLogoutIfAccessDenied = (): LogoutIfAccessDenied => { authProvider .checkAuth({}) .then(() => { - notify('ra.notification.logged_out', 'warning'); + if (logoutUser) { + notify( + 'ra.notification.logged_out', + 'warning' + ); + } else { + notify( + 'ra.notification.not_authorized', + 'warning' + ); + } }) .catch(() => {}); } @@ -76,11 +90,16 @@ const useLogoutIfAccessDenied = (): LogoutIfAccessDenied => { : error && error.redirectTo ? error.redirectTo : undefined; - logout({}, redirectTo); + + if (logoutUser) { + logout({}, redirectTo); + } else { + history.push(redirectTo); + } return true; }), - [authProvider, logout, notify] + [authProvider, logout, notify, history] ); return authProvider ? logoutIfAccessDenied diff --git a/packages/ra-language-english/src/index.ts b/packages/ra-language-english/src/index.ts index ed4d50a3dc1..62b3003e4c1 100644 --- a/packages/ra-language-english/src/index.ts +++ b/packages/ra-language-english/src/index.ts @@ -138,6 +138,7 @@ const englishMessages: TranslationMessages = { 'Cannot load the translations for the specified language', canceled: 'Action cancelled', logged_out: 'Your session has ended, please reconnect.', + not_authorized: "You're not authorized to access this resource.", }, validation: { required: 'Required', diff --git a/packages/ra-language-french/src/index.ts b/packages/ra-language-french/src/index.ts index b01ed2cd9f5..ec1c8a15c87 100644 --- a/packages/ra-language-french/src/index.ts +++ b/packages/ra-language-french/src/index.ts @@ -143,6 +143,8 @@ const frenchMessages: TranslationMessages = { 'Erreur de chargement des traductions pour la langue sélectionnée', canceled: 'Action annulée', logged_out: 'Votre session a pris fin, veuillez vous reconnecter.', + not_authorized: + "Vous n'êtes pas autorisé(e) à accéder à cette ressource.", }, validation: { required: 'Ce champ est requis',