Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Offer logout option in auth provider #6326

19 changes: 19 additions & 0 deletions docs/Authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 44 additions & 7 deletions packages/ra-core/src/auth/useLogoutIfAccessDenied.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -58,6 +60,16 @@ const notify = jest.fn();
//@ts-expect-error
useNotify.mockImplementation(() => notify);

function renderInRouter(children) {
const history = createMemoryHistory();
const api = render(<Router history={history}>{children}</Router>);

return {
...api,
history,
};
}

describe('useLogoutIfAccessDenied', () => {
afterEach(() => {
//@ts-expect-error
Expand All @@ -67,11 +79,12 @@ describe('useLogoutIfAccessDenied', () => {
});

it('should not logout if passed no error', async () => {
const { queryByText } = render(
const { queryByText } = renderInRouter(
<AuthContext.Provider value={authProvider}>
<TestComponent />
</AuthContext.Provider>
);

await waitFor(() => {
expect(authProvider.logout).toHaveBeenCalledTimes(0);
expect(notify).toHaveBeenCalledTimes(0);
Expand All @@ -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(
<AuthContext.Provider value={authProvider}>
<TestComponent error={new Error()} />
</AuthContext.Provider>
Expand All @@ -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(
<AuthContext.Provider value={authProvider}>
<TestComponent error={new Error('denied')} />
</AuthContext.Provider>
Expand All @@ -106,7 +119,7 @@ describe('useLogoutIfAccessDenied', () => {
});

it('should not send multiple notifications if already logged out', async () => {
const { queryByText } = render(
const { queryByText } = renderInRouter(
<AuthContext.Provider value={authProvider}>
<TestComponent error={new Error('denied')} />
<TestComponent error={new Error('denied')} />
Expand All @@ -129,7 +142,7 @@ describe('useLogoutIfAccessDenied', () => {
index++; // answers immediately first, then after 100ms the second time
}),
};
const { queryByText } = render(
const { queryByText } = renderInRouter(
<AuthContext.Provider value={delayedAuthProvider}>
<TestComponent />
<TestComponent />
Expand All @@ -143,7 +156,7 @@ describe('useLogoutIfAccessDenied', () => {
});

it('should logout without showing a notification if disableAuthentication is true', async () => {
const { queryByText } = render(
const { queryByText } = renderInRouter(
<AuthContext.Provider value={authProvider}>
<TestComponent
error={new Error('denied')}
Expand All @@ -159,7 +172,7 @@ describe('useLogoutIfAccessDenied', () => {
});

it('should logout without showing a notification if authProvider returns error with message false', async () => {
const { queryByText } = render(
const { queryByText } = renderInRouter(
<AuthContext.Provider
value={{
...authProvider,
Expand All @@ -177,4 +190,28 @@ describe('useLogoutIfAccessDenied', () => {
expect(queryByText('logged in')).toBeNull();
});
});

it('should not logout the user if logoutUser is set to false', async () => {
const { queryByText, history } = renderInRouter(
<AuthContext.Provider
value={{
...authProvider,
checkError: () => {
return Promise.reject({
logoutUser: false,
redirectTo: '/unauthorized',
});
},
}}
>
<TestComponent />
</AuthContext.Provider>
);
await waitFor(() => {
expect(authProvider.logout).toHaveBeenCalledTimes(0);
expect(notify).toHaveBeenCalledTimes(1);
expect(queryByText('logged in')).toBeNull();
expect(history.location.pathname).toBe('/unauthorized');
});
});
});
25 changes: 22 additions & 3 deletions packages/ra-core/src/auth/useLogoutIfAccessDenied.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand All @@ -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(() => {});
}
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/ra-language-english/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions packages/ra-language-french/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down