Skip to content

Commit

Permalink
Merge pull request #3269 from marmelab/auth-provider-logout
Browse files Browse the repository at this point in the history
[RFR] Delegate the redirection after logout to authProvider
  • Loading branch information
fzaninotto authored Jul 22, 2019
2 parents 7902574 + 52ea7e7 commit 35e5e50
Show file tree
Hide file tree
Showing 6 changed files with 342 additions and 95 deletions.
6 changes: 6 additions & 0 deletions docs/Authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ export default (type, params) => {

The `authProvider` is also a good place to notify the authentication API that the user credentials are no longer valid after logout.

Note that the `authProvider` can return the url to which the user will be redirected once logged out. By default, this is the `/login` route.

## Catching Authentication Errors On The API

If the API requires authentication, and the user credentials are missing in the request or invalid, the API usually answers with an HTTP error code 401 or 403.
Expand Down Expand Up @@ -167,6 +169,8 @@ export default (type, params) => {
};
```

Note that react-admin will call the `authProvider` with the `AUTH_LOGOUT` type before redirecting when you reject the promise and will use the url which may have been return by the call to `AUTH_LOGOUT`.

## Checking Credentials During Navigation

Redirecting to the login page whenever a REST response uses a 401 status code is usually not enough, because react-admin keeps data on the client side, and could display stale data while contacting the server - even after the credentials are no longer valid.
Expand Down Expand Up @@ -221,6 +225,8 @@ 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
Expand Down
8 changes: 8 additions & 0 deletions packages/ra-core/src/actions/clearActions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const CLEAR_STATE = 'RA/CLEAR_STATE';

// The CLEAR_STATE action will completely reset the react-admin redux state to its initial value.
// This should only be called once the user has been redirected to a page which do not use the
// state such as the login page.
export const clearState = () => ({
type: CLEAR_STATE,
});
1 change: 1 addition & 0 deletions packages/ra-core/src/actions/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './accumulateActions';
export * from './authActions';
export * from './clearActions';
export * from './dataActions';
export * from './fetchActions';
export * from './filterActions';
Expand Down
4 changes: 2 additions & 2 deletions packages/ra-core/src/createAdminStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import { all, fork } from 'redux-saga/effects';
import { History } from 'history';

import { AuthProvider, DataProvider, I18nProvider } from './types';
import { USER_LOGOUT } from './actions/authActions';
import createAppReducer from './reducer';
import { adminSaga } from './sideEffect';
import { defaultI18nProvider } from './i18n';
import formMiddleware from './form/formMiddleware';
import { CLEAR_STATE } from './actions/clearActions';

interface Window {
__REDUX_DEVTOOLS_EXTENSION__?: () => () => void;
Expand Down Expand Up @@ -45,7 +45,7 @@ export default ({
);

const resettableAppReducer = (state, action) =>
appReducer(action.type !== USER_LOGOUT ? state : undefined, action);
appReducer(action.type !== CLEAR_STATE ? state : undefined, action);
const saga = function* rootSaga() {
yield all(
[
Expand Down
228 changes: 228 additions & 0 deletions packages/ra-core/src/sideEffect/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import { runSaga } from 'redux-saga';
import {
handleLogin,
handleCheck,
handleLogout,
handleFetchError,
} from './auth';
import { AUTH_LOGIN, AUTH_CHECK, AUTH_LOGOUT, AUTH_ERROR } from '../auth';
import {
USER_LOGIN_LOADING,
USER_LOGIN_SUCCESS,
USER_LOGIN_FAILURE,
} from '../actions/authActions';
import { push, replace } from 'connected-react-router';
import {
showNotification,
hideNotification,
} from '../actions/notificationActions';
import { clearState } from '../actions/clearActions';

const wait = (timeout = 100) =>
new Promise(resolve => setTimeout(resolve, timeout));

describe('Auth saga', () => {
describe('Login saga', () => {
test('Handle successful login', async () => {
const dispatch = jest.fn();
const authProvider = jest.fn().mockResolvedValue({ role: 'admin' });
const action = {
payload: {
login: 'user',
password: 'password123',
},
meta: {
pathName: '/posts',
},
};

await runSaga({ dispatch }, handleLogin(authProvider), action);
expect(authProvider).toHaveBeenCalledWith(AUTH_LOGIN, {
login: 'user',
password: 'password123',
});
expect(dispatch).toHaveBeenCalledWith({ type: USER_LOGIN_LOADING });
expect(dispatch).toHaveBeenCalledWith({
type: USER_LOGIN_SUCCESS,
payload: { role: 'admin' },
});
expect(dispatch).toHaveBeenCalledWith(push('/posts'));
});

test('Handle successful login with redirection from previous state', async () => {
const dispatch = jest.fn();
const authProvider = jest.fn().mockResolvedValue({ role: 'admin' });
const action = {
payload: {
login: 'user',
password: 'password123',
},
meta: {},
};

await runSaga(
{
dispatch,
getState: () => ({
router: {
location: { state: { nextPathname: '/posts/1' } },
},
}),
},
handleLogin(authProvider),
action
);

expect(authProvider).toHaveBeenCalledWith(AUTH_LOGIN, {
login: 'user',
password: 'password123',
});
expect(dispatch).toHaveBeenCalledWith({ type: USER_LOGIN_LOADING });
expect(dispatch).toHaveBeenCalledWith({
type: USER_LOGIN_SUCCESS,
payload: { role: 'admin' },
});
expect(dispatch).toHaveBeenCalledWith(push('/posts/1'));
});

test('Handle failed login', async () => {
const dispatch = jest.fn();
const error = { message: 'Bazinga!' };
const authProvider = jest.fn().mockRejectedValue(error);
const action = {
payload: {
login: 'user',
password: 'password123',
},
meta: {
pathName: '/posts',
},
};

await runSaga({ dispatch }, handleLogin(authProvider), action);
expect(authProvider).toHaveBeenCalledWith(AUTH_LOGIN, {
login: 'user',
password: 'password123',
});
expect(dispatch).toHaveBeenCalledWith({ type: USER_LOGIN_LOADING });
expect(dispatch).toHaveBeenCalledWith({
type: USER_LOGIN_FAILURE,
error,
meta: { auth: true },
});
expect(dispatch).toHaveBeenCalledWith(
showNotification('Bazinga!', 'warning')
);
});
});
describe('Check saga', () => {
test('Handle successful check', async () => {
const dispatch = jest.fn();
const authProvider = jest.fn().mockResolvedValue({ role: 'admin' });
const action = {
payload: {
resource: 'posts',
},
meta: {
pathName: '/posts',
},
};

await runSaga({ dispatch }, handleCheck(authProvider), action);
expect(authProvider).toHaveBeenCalledWith(AUTH_CHECK, {
resource: 'posts',
});
expect(dispatch).not.toHaveBeenCalled();
});

test('Handle failed check', async () => {
const dispatch = jest.fn();
const error = { message: 'Bazinga!' };
const authProvider = jest
.fn()
.mockRejectedValueOnce(error)
.mockResolvedValueOnce('/custom');

const action = {
payload: {
resource: 'posts',
},
meta: {
pathName: '/posts',
},
};

await runSaga({ dispatch }, handleCheck(authProvider), action);
expect(authProvider).toHaveBeenCalledWith(AUTH_CHECK, {
resource: 'posts',
});
expect(authProvider).toHaveBeenCalledWith(AUTH_LOGOUT);
await wait();
expect(dispatch).toHaveBeenCalledWith(
replace({
pathname: '/custom',
state: { nextPathname: '/posts' },
})
);
expect(dispatch).toHaveBeenCalledWith(clearState());
expect(dispatch).toHaveBeenCalledWith(
showNotification('Bazinga!', 'warning')
);
});
});
describe('Logout saga', () => {
test('Handle logout', async () => {
const dispatch = jest.fn();
const authProvider = jest.fn().mockResolvedValue('/custom');
const action = {
payload: {
resource: 'posts',
},
meta: {
pathName: '/posts',
},
};

await runSaga({ dispatch }, handleLogout(authProvider), action);
expect(authProvider).toHaveBeenCalledWith(AUTH_LOGOUT);
expect(dispatch).toHaveBeenCalledWith(push('/custom'));
expect(dispatch).toHaveBeenCalledWith(clearState());
});
});
describe('Fetch error saga', () => {
test('Handle errors when authProvider throws', async () => {
const dispatch = jest.fn();
const error = { message: 'Bazinga!' };
const authProvider = jest
.fn()
.mockRejectedValueOnce(undefined)
.mockResolvedValueOnce('/custom');
const action = {
error,
};

await runSaga(
{
dispatch,
getState: () => ({ router: { location: '/posts' } }),
},
handleFetchError(authProvider),
action
);
expect(authProvider).toHaveBeenCalledWith(AUTH_ERROR, error);
expect(authProvider).toHaveBeenCalledWith(AUTH_LOGOUT);
await wait();
expect(dispatch).toHaveBeenCalledWith(
push({
pathname: '/custom',
state: { nextPathname: '/posts' },
})
);
expect(dispatch).toHaveBeenCalledWith(hideNotification());
expect(dispatch).toHaveBeenCalledWith(
showNotification('ra.notification.logged_out', 'warning')
);
expect(dispatch).toHaveBeenCalledWith(clearState());
});
});
});
Loading

0 comments on commit 35e5e50

Please sign in to comment.