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

[RFR] Delegate the redirection after logout to authProvider #3269

Merged
merged 8 commits into from
Jul 22, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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