Merge pull request #123 from sharetribe/refactor-authentication

Refactor authentication
This commit is contained in:
Kimmo Puputti 2017-04-28 11:20:31 +03:00 committed by GitHub
commit 00224c34f2
13 changed files with 356 additions and 324 deletions

View file

@ -41,8 +41,8 @@ describe('Application', () => {
'/s/map': 'Search page: map',
'/l/listing-title-slug/1234': 'Loading listing data',
'/u/1234': 'Profile page with display name: 1234',
'/login': 'Authentication page: login tab',
'/signup': 'Authentication page: signup tab',
'/login': 'Log in',
'/signup': 'Signup',
'/password': 'Request new password',
'/password/forgotten': 'Request new password',
'/password/change': 'Type new password',

View file

@ -1,10 +1,11 @@
import React, { PropTypes } from 'react';
import { compose } from 'redux';
import { connect } from 'react-redux';
import { Redirect } from 'react-router-dom';
import { FormattedMessage } from 'react-intl';
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
import { PageLayout, NamedRedirect } from '../../components';
import { LoginForm, SignUpForm } from '../../containers';
import { login } from '../../ducks/Auth.duck';
import { login, authenticationInProgress } from '../../ducks/Auth.duck';
import css from './AuthenticationPage.css';
@ -14,24 +15,33 @@ export const AuthenticationPageComponent = props => {
tab,
isAuthenticated,
loginError,
authInProgress,
onLoginSubmit,
onSignUpSubmit,
intl,
} = props;
const isLogin = tab === 'login';
const from = location.state && location.state.from ? location.state.from : null;
const authRedirect = from ? <Redirect to={from} /> : <NamedRedirect name="LandingPage" />;
// TODO: use FlashMessages for auth errors
/* eslint-disable no-console */
if (loginError && console && console.error) {
// TODO: use FlashMessages for auth errors
console.error(loginError);
}
/* eslint-enable no-console */
const loginTitle = intl.formatMessage({
id: 'AuthenticationPage.loginPageTitle',
});
const signupTitle = intl.formatMessage({
id: 'AuthenticationPage.signupPageTitle',
});
const title = isLogin ? loginTitle : signupTitle;
return (
<PageLayout title={`Authentication page: ${tab} tab`}>
<PageLayout title={title}>
<div className={css.root}>
{isAuthenticated ? authRedirect : null}
{loginError
@ -46,7 +56,7 @@ export const AuthenticationPageComponent = props => {
</p>
: null}
{isLogin
? <LoginForm onSubmit={onLoginSubmit} />
? <LoginForm onSubmit={onLoginSubmit} inProgress={authInProgress} />
: <SignUpForm onSubmit={onSignUpSubmit} />}
{/* {isLogin
? <NamedLink name="SignUpPage" to={{ state: from ? { from } : null }}>Sign up</NamedLink>
@ -70,21 +80,29 @@ AuthenticationPageComponent.propTypes = {
tab: oneOf(['login', 'signup']),
isAuthenticated: bool.isRequired,
loginError: instanceOf(Error),
authInProgress: bool.isRequired,
onLoginSubmit: func.isRequired,
onSignUpSubmit: func.isRequired,
intl: intlShape.isRequired,
};
const mapStateToProps = state => ({
isAuthenticated: state.Auth.isAuthenticated,
loginError: state.Auth.loginError,
});
const mapStateToProps = state => {
const { isAuthenticated, loginError } = state.Auth;
return {
isAuthenticated,
loginError,
authInProgress: authenticationInProgress(state),
};
};
const mapDispatchToProps = dispatch => ({
onLoginSubmit: ({ email, password }) => dispatch(login(email, password)),
onSignUpSubmit: ({ email, password }) => dispatch(login(email, password)),
onSignUpSubmit: () => {
console.log('signup submit'); // eslint-disable-line
},
});
const AuthenticationPage = connect(mapStateToProps, mapDispatchToProps)(
const AuthenticationPage = compose(connect(mapStateToProps, mapDispatchToProps), injectIntl)(
AuthenticationPageComponent
);

View file

@ -1,14 +1,18 @@
import React from 'react';
import { renderShallow } from '../../util/test-helpers';
import { fakeIntl } from '../../util/test-data';
import { AuthenticationPageComponent } from './AuthenticationPage';
describe('AuthenticationPageComponent', () => {
it('matches snapshot', () => {
const props = {
tab: 'login',
location: { state: { from: '/protected' } },
isAuthenticated: false,
authInProgress: false,
onLoginSubmit: () => null,
onSignUpSubmit: () => null,
intl: fakeIntl,
};
const tree = renderShallow(<AuthenticationPageComponent {...props} />);
expect(tree).toMatchSnapshot();

View file

@ -1,6 +1,6 @@
exports[`AuthenticationPageComponent matches snapshot 1`] = `
<Connect(withRouter(PageLayout))
title="Authentication page: signup tab">
title="AuthenticationPage.loginPageTitle">
<div>
<p>
<FormattedMessage
@ -11,6 +11,7 @@ exports[`AuthenticationPageComponent matches snapshot 1`] = `
</code>
</p>
<ReduxForm
inProgress={false}
onSubmit={[Function]} />
</div>
</Connect(withRouter(PageLayout))>

View file

@ -1,26 +1,49 @@
import React from 'react';
import React, { PropTypes } from 'react';
import { compose } from 'redux';
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
import { reduxForm, propTypes as formPropTypes } from 'redux-form';
import { Button, LabeledField } from '../../components';
import css from './LoginForm.css';
const LoginForm = props => {
const { handleSubmit, pristine, submitting } = props;
const LoginFormComponent = props => {
const { handleSubmit, pristine, submitting, inProgress, intl } = props;
const emailLabel = intl.formatMessage({
id: 'LoginForm.emailLabel',
});
const passwordLabel = intl.formatMessage({
id: 'LoginForm.passwordLabel',
});
const submitDisabled = pristine || submitting || inProgress;
return (
<form className={css.form} onSubmit={handleSubmit}>
<div>
<div className={css.row}>
<LabeledField name="email" type="email" label="Email" />
<LabeledField name="email" type="email" label={emailLabel} />
</div>
<div className={css.row}>
<LabeledField name="password" type="password" label="Password" />
<LabeledField name="password" type="password" label={passwordLabel} />
</div>
</div>
<Button className={css.button} type="submit" disabled={pristine || submitting}>Log in</Button>
<Button className={css.button} type="submit" disabled={submitDisabled}>
<FormattedMessage id="LoginForm.logIn" />
</Button>
</form>
);
};
LoginForm.propTypes = { ...formPropTypes };
LoginFormComponent.defaultProps = { inProgress: false };
export default reduxForm({ form: 'login' })(LoginForm);
const { bool } = PropTypes;
LoginFormComponent.propTypes = {
...formPropTypes,
inProgress: bool,
intl: intlShape.isRequired,
};
const formName = 'LoginForm';
const LoginForm = compose(reduxForm({ form: formName }), injectIntl)(LoginFormComponent);
export default LoginForm;

View file

@ -48,7 +48,9 @@ exports[`LoginForm matches snapshot 1`] = `
className=""
disabled={true}
type="submit">
Log in
<span>
Log in
</span>
</button>
</form>
`;

View file

@ -1,8 +1,12 @@
import React, { PropTypes } from 'react';
import { compose } from 'redux';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { logout } from '../../ducks/Auth.duck';
import { logout, authenticationInProgress } from '../../ducks/Auth.duck';
import { NamedLink, Button } from '../../components';
import { withFlattenedRoutes } from '../../util/contextHelpers';
import { pathByRouteName } from '../../util/routes';
import * as propTypes from '../../util/propTypes';
import css from './Topbar.css';
@ -10,15 +14,22 @@ import css from './Topbar.css';
const House = () => <span dangerouslySetInnerHTML={{ __html: '&#127968;' }} />;
/* eslint-enable react/no-danger */
const Topbar = props => {
const { isAuthenticated, onLogout, history } = props;
const TopbarComponent = props => {
const { isAuthenticated, authInProgress, onLogout, history, flattenedRoutes } = props;
const handleLogout = () => {
// History push function is passed to the action to enable
// redirect to home when logout succeeds.
onLogout(history.push);
const path = pathByRouteName('LandingPage', flattenedRoutes);
history.push(path);
onLogout().then(() => {
// TODO: show flash message
console.log('logged out'); // eslint-disable-line
});
};
const authAction = isAuthenticated
? <Button className={css.logoutButton} onClick={handleLogout}>Logout</Button>
: <NamedLink name="LogInPage">Login</NamedLink>;
return (
<div className={css.container}>
<div>
@ -27,30 +38,44 @@ const Topbar = props => {
</NamedLink>
</div>
<div className={css.user}>
{isAuthenticated
? <Button className={css.logoutButton} onClick={handleLogout}>Logout</Button>
: <NamedLink name="LogInPage">Login</NamedLink>}
{authInProgress ? null : authAction}
</div>
</div>
);
};
Topbar.defaultProps = { user: null };
TopbarComponent.defaultProps = { user: null };
const { bool, func, shape } = PropTypes;
const { bool, func, shape, arrayOf } = PropTypes;
Topbar.propTypes = {
TopbarComponent.propTypes = {
isAuthenticated: bool.isRequired,
authInProgress: bool.isRequired,
onLogout: func.isRequired,
// from withRouter
history: shape({
push: func.isRequired,
}).isRequired,
// from withFlattenedRoutes
flattenedRoutes: arrayOf(propTypes.route),
};
const mapStateToProps = state => ({ isAuthenticated: state.Auth.isAuthenticated });
const mapStateToProps = state => {
const { isAuthenticated } = state.Auth;
return {
isAuthenticated,
authInProgress: authenticationInProgress(state),
};
};
const mapDispatchToProps = dispatch => ({ onLogout: historyPush => dispatch(logout(historyPush)) });
export default connect(mapStateToProps, mapDispatchToProps)(withRouter(Topbar));
const Topbar = compose(
connect(mapStateToProps, mapDispatchToProps),
withRouter,
withFlattenedRoutes
)(TopbarComponent);
export default Topbar;

View file

@ -1,8 +1,4 @@
/* eslint-disable no-constant-condition */
/**
* Authentication duck.
*/
import { call, put, take, cancel, fork, takeLatest } from 'redux-saga/effects';
import { clearCurrentUser, fetchCurrentUser } from './user.duck';
const authenticated = authInfo => authInfo.grantType === 'refresh_token';
@ -27,7 +23,9 @@ const initialState = {
isAuthenticated: false,
authInfoError: null,
loginError: null,
loginInProgress: false,
logoutError: null,
logoutInProgress: false,
};
export default function reducer(state = initialState, action = {}) {
@ -38,101 +36,87 @@ export default function reducer(state = initialState, action = {}) {
case AUTH_INFO_SUCCESS:
return { ...state, authInfoLoaded: true, isAuthenticated: authenticated(payload) };
case AUTH_INFO_ERROR:
return { ...state, authInfoError: payload };
console.error(payload); // eslint-disable-line
return { ...state, authInfoLoaded: true, authInfoError: payload };
case LOGIN_REQUEST:
return { ...state, loginError: null, logoutError: null };
return { ...state, loginInProgress: true, loginError: null, logoutError: null };
case LOGIN_SUCCESS:
return { ...state, isAuthenticated: true };
return { ...state, loginInProgress: false, isAuthenticated: true };
case LOGIN_ERROR:
return { ...state, loginError: payload };
return { ...state, loginInProgress: false, loginError: payload };
case LOGOUT_REQUEST:
return { ...state, loginError: null, logoutError: null };
return { ...state, logoutInProgress: true, loginError: null, logoutError: null };
case LOGOUT_SUCCESS:
return { ...state, isAuthenticated: false };
return { ...state, logoutInProgress: false, isAuthenticated: false };
case LOGOUT_ERROR:
return { ...state, logoutError: payload };
return { ...state, logoutInProgress: false, logoutError: payload };
default:
return state;
}
}
// ================ Selectors ================ //
export const authenticationInProgress = state => {
const { loginInProgress, logoutInProgress } = state.Auth;
return loginInProgress || logoutInProgress;
};
// ================ Action creators ================ //
export const authInfo = () => ({ type: AUTH_INFO_REQUEST });
export const authInfoRequest = () => ({ type: AUTH_INFO_REQUEST });
export const authInfoSuccess = info => ({ type: AUTH_INFO_SUCCESS, payload: info });
export const authInfoError = error => ({ type: AUTH_INFO_ERROR, payload: error, error: true });
export const login = (username, password) => ({
type: LOGIN_REQUEST,
payload: { username, password },
});
export const loginRequest = () => ({ type: LOGIN_REQUEST });
export const loginSuccess = () => ({ type: LOGIN_SUCCESS });
export const loginError = error => ({ type: LOGIN_ERROR, payload: error, error: true });
export const logout = historyPush => ({ type: LOGOUT_REQUEST, payload: { historyPush } });
export const logoutRequest = () => ({ type: LOGOUT_REQUEST });
export const logoutSuccess = () => ({ type: LOGOUT_SUCCESS });
export const logoutError = error => ({ type: LOGOUT_ERROR, payload: error, error: true });
// ================ Worker sagas ================ //
// ================ Thunks ================ //
export function* callAuthInfo(sdk) {
try {
const info = yield call(sdk.authInfo);
yield put(authInfoSuccess(info));
} catch (e) {
yield put(authInfoError(e));
}
}
export const authInfo = () =>
(dispatch, getState, sdk) => {
dispatch(authInfoRequest());
return sdk
.authInfo()
.then(info => dispatch(authInfoSuccess(info)))
.catch(e => dispatch(authInfoError(e)));
};
export function* callLogin(action, sdk) {
const { payload } = action;
const { username, password } = payload;
try {
yield call(sdk.login, { username, password });
yield put(loginSuccess());
} catch (e) {
yield put(loginError(e));
}
}
export function* callLogout(action, sdk) {
const { payload } = action;
const { historyPush } = payload;
try {
yield call(sdk.logout);
yield put(logoutSuccess());
yield call(historyPush, '/');
} catch (e) {
yield put(logoutError(e));
}
}
// ================ Watcher sagas ================ //
export function* watchAuthInfo(sdk) {
yield takeLatest(AUTH_INFO_REQUEST, callAuthInfo, sdk);
}
export function* watchAuth(sdk) {
let task;
while (true) {
// Take either login or logout action
const action = yield take([LOGIN_REQUEST, LOGOUT_REQUEST]);
// Previous task should be cancelled if a new login or logout
// action is received
if (task) {
yield cancel(task);
export const login = (username, password) =>
(dispatch, getState, sdk) => {
if (authenticationInProgress(getState())) {
return Promise.reject(new Error('Login or logout already in progress'));
}
dispatch(loginRequest());
// Fork the correct worker and continue waiting for actions
if (action.type === LOGIN_REQUEST) {
task = yield fork(callLogin, action, sdk);
} else if (action.type === LOGOUT_REQUEST) {
task = yield fork(callLogout, action, sdk);
// Note that the thunk does not reject when the login fails, it
// just dispatches the login error action.
return sdk
.login({ username, password })
.then(() => dispatch(fetchCurrentUser()))
.then(() => dispatch(loginSuccess()))
.catch(e => dispatch(loginError(e)));
};
export const logout = () =>
(dispatch, getState, sdk) => {
if (authenticationInProgress(getState())) {
return Promise.reject(new Error('Login or logout already in progress'));
}
}
}
dispatch(logoutRequest());
// Not that the thunk does not reject when the logout fails, it
// just dispatches the logout error action.
return sdk
.logout()
.then(() => dispatch(clearCurrentUser()))
.then(() => dispatch(logoutSuccess()))
.catch(e => dispatch(logoutError(e)));
};

View file

@ -1,5 +1,4 @@
import { call, put, take, fork, cancel } from 'redux-saga/effects';
import { createMockTask } from 'redux-saga/utils';
import { clearCurrentUser } from './user.duck';
import reducer, {
LOGIN_REQUEST,
LOGOUT_REQUEST,
@ -10,10 +9,12 @@ import reducer, {
authInfoSuccess,
authInfoError,
login,
loginRequest,
loginSuccess,
loginError,
callLogin,
logout,
logoutRequest,
logoutSuccess,
logoutError,
callLogout,
@ -29,32 +30,35 @@ describe('Auth duck', () => {
expect(state.authInfoError).toBeNull();
expect(state.loginError).toBeNull();
expect(state.logoutError).toBeNull();
expect(state.loginInProgress).toEqual(false);
expect(state.logoutInProgress).toEqual(false);
});
it('should login successfully', () => {
const username = 'x@x.x';
const password = 'pass';
const initialState = reducer();
const loginRequestState = reducer(initialState, loginRequest());
expect(loginRequestState.isAuthenticated).toEqual(false);
expect(loginRequestState.loginError).toBeNull();
expect(loginRequestState.loginInProgress).toEqual(true);
let state = reducer();
state = reducer(state, login(username, password));
expect(state.isAuthenticated).toEqual(false);
expect(state.loginError).toBeNull();
state = reducer(state, loginSuccess());
expect(state.isAuthenticated).toEqual(true);
expect(state.loginError).toBeNull();
const loginSuccessState = reducer(loginRequestState, loginSuccess());
expect(loginSuccessState.isAuthenticated).toEqual(true);
expect(loginSuccessState.loginError).toBeNull();
expect(loginSuccessState.loginInProgress).toEqual(false);
});
it('should handle failed login', () => {
let state = reducer();
state = reducer(state, login('username', 'pass'));
expect(state.isAuthenticated).toEqual(false);
expect(state.loginError).toBeNull();
let initialState = reducer();
const loginRequestState = reducer(initialState, loginRequest());
expect(loginRequestState.isAuthenticated).toEqual(false);
expect(loginRequestState.loginError).toBeNull();
expect(loginRequestState.loginInProgress).toEqual(true);
const error = new Error('test error');
state = reducer(state, loginError(error));
expect(state.isAuthenticated).toEqual(false);
expect(state.loginError).toEqual(error);
const loginErrorState = reducer(loginRequestState, loginError(error));
expect(loginErrorState.isAuthenticated).toEqual(false);
expect(loginErrorState.loginError).toEqual(error);
expect(loginErrorState.loginInProgress).toEqual(false);
});
it('should set initial state for unauthenticated users', () => {
@ -88,177 +92,143 @@ describe('Auth duck', () => {
});
});
describe('login worker', () => {
it('should succeed when API call fulfills', () => {
const username = 'username';
describe('login thunk', () => {
it('should dispatch success', () => {
const dispatch = jest.fn();
const initialState = reducer();
const getState = () => ({ Auth: initialState });
const sdk = { login: jest.fn(() => Promise.resolve({})) };
const username = 'x.x@example.com';
const password = 'pass';
const payload = { username, password };
const sdk = { login: jest.fn() };
const loginAction = login(username, password);
const worker = callLogin(loginAction, sdk);
expect(worker.next()).toEqual({
done: false,
value: call(sdk.login, { username, password }),
});
expect(worker.next(payload)).toEqual({ done: false, value: put(loginSuccess()) });
expect(worker.next().done).toEqual(true);
expect(sdk.login).not.toHaveBeenCalled();
});
it('should fail when API call rejects', () => {
const username = 'username';
const password = 'pass';
const sdk = { login: jest.fn() };
const loginAction = login(username, password);
const worker = callLogin(loginAction, sdk);
expect(worker.next()).toEqual({
done: false,
value: call(sdk.login, { username, password }),
return login(username, password)(dispatch, getState, sdk).then(() => {
expect(sdk.login.mock.calls).toEqual([[{ username, password }]]);
expect(dispatch.mock.calls).toEqual([
[loginRequest()],
[expect.anything()], // fetchCurrentUser
[loginSuccess()],
]);
});
const error = new Error('Test login failed');
expect(worker.throw(error)).toEqual({ done: false, value: put(loginError(error)) });
expect(worker.next().done).toEqual(true);
expect(sdk.login).not.toHaveBeenCalled();
});
it('should dispatch error', () => {
const dispatch = jest.fn();
const initialState = reducer();
const getState = () => ({ Auth: initialState });
const error = new Error('could not login');
const sdk = { login: jest.fn(() => Promise.reject(error)) };
const username = 'x.x@example.com';
const password = 'pass';
return login(username, password)(dispatch, getState, sdk).then(() => {
expect(sdk.login.mock.calls).toEqual([[{ username, password }]]);
expect(dispatch.mock.calls).toEqual([[loginRequest()], [loginError(error)]]);
});
});
it('should reject if another login is in progress', () => {
const dispatch = jest.fn();
const initialState = reducer();
const loginInProgressState = reducer(initialState, loginRequest());
const getState = () => ({ Auth: loginInProgressState });
const sdk = { login: jest.fn(() => Promise.resolve({})) };
const username = 'x.x@example.com';
const password = 'pass';
return login(username, password)(dispatch, getState, sdk).then(
() => {
throw new Error('should not succeed');
},
e => {
expect(e.message).toEqual('Login or logout already in progress');
expect(sdk.login.mock.calls.length).toEqual(0);
expect(dispatch.mock.calls.length).toEqual(0);
}
);
});
it('should reject if logout is in progress', () => {
const dispatch = jest.fn();
const initialState = reducer();
const logoutInProgressState = reducer(initialState, logoutRequest());
const getState = () => ({ Auth: logoutInProgressState });
const sdk = { login: jest.fn(() => Promise.resolve({})) };
const username = 'x.x@example.com';
const password = 'pass';
return login(username, password)(dispatch, getState, sdk).then(
() => {
throw new Error('should not succeed');
},
e => {
expect(e.message).toEqual('Login or logout already in progress');
expect(sdk.login.mock.calls.length).toEqual(0);
expect(dispatch.mock.calls.length).toEqual(0);
}
);
});
});
describe('logout worker', () => {
it('should redirect to root after logout', () => {
const sdk = { logout: jest.fn() };
const historyPush = jest.fn();
const action = logout(historyPush);
const worker = callLogout(action, sdk);
expect(worker.next()).toEqual({ done: false, value: call(sdk.logout) });
expect(worker.next()).toEqual({ done: false, value: put(logoutSuccess()) });
expect(worker.next()).toEqual({ done: false, value: call(historyPush, '/') });
expect(worker.next().done).toEqual(true);
expect(sdk.logout).not.toHaveBeenCalled();
expect(historyPush).not.toHaveBeenCalled();
describe('logout thunk', () => {
it('should dispatch success', () => {
const dispatch = jest.fn();
const initialState = reducer();
const getState = () => ({ Auth: initialState });
const sdk = { logout: jest.fn(() => Promise.resolve({})) };
return logout()(dispatch, getState, sdk).then(() => {
expect(sdk.logout.mock.calls.length).toEqual(1);
expect(dispatch.mock.calls).toEqual([
[logoutRequest()],
[clearCurrentUser()],
[logoutSuccess()],
]);
});
});
it('should dispatch error', () => {
const dispatch = jest.fn();
const initialState = reducer();
const getState = () => ({ Auth: initialState });
const error = new Error('could not logout');
const sdk = { logout: jest.fn(() => Promise.reject(error)) };
it('should not redirect if logout fails', () => {
const sdk = { logout: jest.fn() };
const historyPush = jest.fn();
const action = logout(historyPush);
const worker = callLogout(action, sdk);
expect(worker.next()).toEqual({ done: false, value: call(sdk.logout) });
const error = new Error('Test logout error');
expect(worker.throw(error)).toEqual({ done: false, value: put(logoutError(error)) });
expect(worker.next().done).toEqual(true);
expect(sdk.logout).not.toHaveBeenCalled();
expect(historyPush).not.toHaveBeenCalled();
return logout()(dispatch, getState, sdk).then(() => {
expect(sdk.logout.mock.calls.length).toEqual(1);
expect(dispatch.mock.calls).toEqual([[logoutRequest()], [logoutError(error)]]);
});
});
});
it('should reject if another logout is in progress', () => {
const dispatch = jest.fn();
const initialState = reducer();
const logoutInProgressState = reducer(initialState, logoutRequest());
const getState = () => ({ Auth: logoutInProgressState });
const sdk = { logout: jest.fn(() => Promise.resolve({})) };
describe('auth watcher', () => {
it('calls login', () => {
const sdk = { login: jest.fn() };
const watcher = watchAuth(sdk);
const loginAction = login('username', 'password');
const takeLoginOrLogout = take([LOGIN_REQUEST, LOGOUT_REQUEST]);
const forkLogin = fork(callLogin, loginAction, sdk);
// The watcher should first take a login or a logout action
expect(watcher.next().value).toEqual(takeLoginOrLogout);
// If we pass it a login action, it should fork the login worker
expect(watcher.next(loginAction).value).toEqual(forkLogin);
// It should continue back at taking a login or a logout action
expect(watcher.next({}).value).toEqual(takeLoginOrLogout);
expect(sdk.login).not.toHaveBeenCalled();
return logout()(dispatch, getState, sdk).then(
() => {
throw new Error('should not succeed');
},
e => {
expect(e.message).toEqual('Login or logout already in progress');
expect(sdk.logout.mock.calls.length).toEqual(0);
expect(dispatch.mock.calls.length).toEqual(0);
}
);
});
it('should reject if login is in progress', () => {
const dispatch = jest.fn();
const initialState = reducer();
const loginInProgressState = reducer(initialState, loginRequest());
const getState = () => ({ Auth: loginInProgressState });
const sdk = { logout: jest.fn(() => Promise.resolve({})) };
it('calls logout', () => {
const sdk = { logout: jest.fn() };
const historyPush = jest.fn();
const watcher = watchAuth(sdk);
const logoutAction = logout(historyPush);
const takeLoginOrLogout = take([LOGIN_REQUEST, LOGOUT_REQUEST]);
const forkLogout = fork(callLogout, logoutAction, sdk);
// The watcher should first take a login or a logout action
expect(watcher.next().value).toEqual(takeLoginOrLogout);
// If we pass it a logout action, it should fork the logout worker
expect(watcher.next(logoutAction).value).toEqual(forkLogout);
// It should continue back at taking a login or a logout action
expect(watcher.next().value).toEqual(takeLoginOrLogout);
expect(sdk.logout).not.toHaveBeenCalled();
expect(historyPush).not.toHaveBeenCalled();
});
it('should cancel login if another login comes', () => {
const sdk = { login: jest.fn(), logout: jest.fn() };
const watcher = watchAuth(sdk);
const loginAction1 = login('username1', 'password1');
const loginAction2 = login('username2', 'password2');
const task = createMockTask();
const takeLoginOrLogout = take([LOGIN_REQUEST, LOGOUT_REQUEST]);
const forkLogin1 = fork(callLogin, loginAction1, sdk);
const forkLogin2 = fork(callLogin, loginAction2, sdk);
const cancelLogin1 = cancel(task);
// First take a login or a logout
expect(watcher.next().value).toEqual(takeLoginOrLogout);
// Passing the first login should fork login worker
expect(watcher.next(loginAction1).value).toEqual(forkLogin1);
// Passing in the task to mock the fork results should make the
// watcher take a login or a logout again
expect(watcher.next(task).value).toEqual(takeLoginOrLogout);
// Passing in another login should cancel the first fork task
expect(watcher.next(loginAction2).value).toEqual(cancelLogin1);
// Then it should fork the second login
expect(watcher.next().value).toEqual(forkLogin2);
// And finally take a login or a logout again
expect(watcher.next().value).toEqual(takeLoginOrLogout);
expect(sdk.login).not.toHaveBeenCalled();
expect(sdk.logout).not.toHaveBeenCalled();
});
it('should cancel login if a logout comes', () => {
const sdk = { login: jest.fn(), logout: jest.fn() };
const historyPush = jest.fn();
const watcher = watchAuth(sdk);
const loginAction = login('username', 'password');
const logoutAction = logout(historyPush);
const task = createMockTask();
const takeLoginOrLogout = take([LOGIN_REQUEST, LOGOUT_REQUEST]);
const forkLogin = fork(callLogin, loginAction, sdk);
const forkLogout = fork(callLogout, logoutAction, sdk);
const cancelLogin = cancel(task);
// First take a login or a logout
expect(watcher.next().value).toEqual(takeLoginOrLogout);
// Passing the login should fork login worker
expect(watcher.next(loginAction).value).toEqual(forkLogin);
// Passing in the task to mock the fork results should make the
// watcher take a login or a logout again
expect(watcher.next(task).value).toEqual(takeLoginOrLogout);
// Passing in logout should cancel the login fork task
expect(watcher.next(logoutAction).value).toEqual(cancelLogin);
// Then it should fork the logout
expect(watcher.next().value).toEqual(forkLogout);
// And finally take a login or a logout again
expect(watcher.next().value).toEqual(takeLoginOrLogout);
expect(sdk.login).not.toHaveBeenCalled();
expect(sdk.logout).not.toHaveBeenCalled();
expect(historyPush).not.toHaveBeenCalled();
return logout()(dispatch, getState, sdk).then(
() => {
throw new Error('should not succeed');
},
e => {
expect(e.message).toEqual('Login or logout already in progress');
expect(sdk.logout.mock.calls.length).toEqual(0);
expect(dispatch.mock.calls.length).toEqual(0);
}
);
});
});
});

View file

@ -4,6 +4,8 @@ export const USERS_ME_REQUEST = 'app/user/USERS_ME_REQUEST';
export const USERS_ME_SUCCESS = 'app/user/USERS_ME_SUCCESS';
export const USERS_ME_ERROR = 'app/user/USERS_ME_ERROR';
export const CLEAR_CURRENT_USER = 'app/user/CLEAR_CURRENT_USER';
export const STRIPE_ACCOUNT_CREATE_REQUEST = 'app/user/STRIPE_ACCOUNT_CREATE_REQUEST';
export const STRIPE_ACCOUNT_CREATE_SUCCESS = 'app/user/STRIPE_ACCOUNT_CREATE_SUCCESS';
export const STRIPE_ACCOUNT_CREATE_ERROR = 'app/user/STRIPE_ACCOUNT_CREATE_ERROR';
@ -27,6 +29,9 @@ export default function reducer(state = initialState, action = {}) {
console.error(payload);
return { ...state, usersMeError: payload };
case CLEAR_CURRENT_USER:
return { ...state, currentUser: null, usersMeError: null };
default:
return state;
}
@ -47,6 +52,8 @@ export const usersMeError = e => ({
error: true,
});
export const clearCurrentUser = () => ({ type: CLEAR_CURRENT_USER });
export const stripeAccountCreateRequest = () => ({ type: STRIPE_ACCOUNT_CREATE_REQUEST });
export const stripeAccountCreateSuccess = response => ({
@ -65,6 +72,13 @@ export const stripeAccountCreateError = e => ({
export const fetchCurrentUser = () =>
(dispatch, getState, sdk) => {
dispatch(usersMeRequest());
const { isAuthenticated } = getState().Auth;
if (!isAuthenticated) {
// Ignore when not logged in, current user should be null
return Promise.resolve({});
}
return sdk.users
.me()
.then(response => {

View file

@ -33,21 +33,19 @@ import routeConfiguration from './routesConfiguration';
import './index.css';
const renderWithAuthInfo = store => {
ReactDOM.render(<ClientApp store={store} />, document.getElementById('root'));
};
// Wait for the store to have the Auth.authInfoLoaded flag, render the
// application when the auth information is present.
const renderWhenAuthInfoLoaded = store => {
const unsubscribe = store.subscribe(() => {
const authInfoLoaded = store.getState().Auth.authInfoLoaded;
if (authInfoLoaded) {
unsubscribe();
renderWithAuthInfo(store);
}
});
store.dispatch(authInfo());
const render = store => {
// If the server already loaded the auth information, render the app
// immediately. Otherwise wait for the flag to be loaded and render
// when auth information is present.
const authInfoLoaded = store.getState().Auth.authInfoLoaded;
const info = authInfoLoaded ? Promise.resolve({}) : store.dispatch(authInfo());
info
.then(() => {
ReactDOM.render(<ClientApp store={store} />, document.getElementById('root'));
})
.catch(e => {
console.error(e); // eslint-disable-line
});
};
const setupStripe = () => {
@ -67,19 +65,8 @@ if (typeof window !== 'undefined') {
const store = configureStore(sdk, initialState);
store.runSaga(createRootSaga(sdk));
setupStripe();
const authInfoLoaded = store.getState().Auth.authInfoLoaded;
// If the server already loaded the auth information, render the app
// immediately. Otherwise wait for the flag to be loaded and render
// when auth information is present.
if (authInfoLoaded) {
renderWithAuthInfo(store);
} else {
renderWhenAuthInfoLoaded(store);
}
render(store);
// Expose stuff for the browser REPL
const actions = bindActionCreators(

View file

@ -1,9 +1,8 @@
import { watchAuthInfo, watchAuth } from './ducks/Auth.duck';
import { watchSdk } from './ducks/sdk.duck';
const createRootSaga = sdk =>
function* rootSaga() {
yield [watchAuthInfo(sdk), watchAuth(sdk), watchSdk(sdk)];
yield [watchSdk(sdk)];
};
export default createRootSaga;

View file

@ -1,6 +1,8 @@
{
"AuthenticationPage.loginFailed": "The email and password you entered did not match our records. Please double-check and try again.",
"AuthenticationPage.loginPageTitle": "Log in",
"AuthenticationPage.loginRequiredFor": "You must log in to view the page at",
"AuthenticationPage.signupPageTitle": "Signup",
"AuthorInfo.host": "Hosted by {authorName}",
"BookingDatesForm.bookingEndTitle": "End date",
"BookingDatesForm.bookingStartTitle": "Start date",
@ -55,19 +57,22 @@
"ListingPage.editListing": "Edit listing",
"ListingPage.loadingListingData": "Loading listing data",
"ListingPage.noListingData": "Could not find listing data",
"LoginForm.emailLabel": "Email",
"LoginForm.logIn": "Log in",
"LoginForm.passwordLabel": "Password",
"ModalInMobile.closeModal": "Close modal",
"OrderDetailsPanel.orderAcceptedTitle": "You have booked {title}.",
"OrderDetailsPanel.orderRejectedTitle": "You requested to book {title}.",
"OrderDetailsPanel.orderRequestedTitle": "You have requested to book {title}.",
"OrderDetailsPanel.orderAcceptedStatus": "{providerName} accepted the booking.",
"OrderDetailsPanel.orderAcceptedTitle": "You have booked {title}.",
"OrderDetailsPanel.orderRejectedStatus": "{providerName} rejected the booking.",
"OrderDetailsPanel.orderRejectedTitle": "You requested to book {title}.",
"OrderDetailsPanel.orderRequestedStatus": "{providerName} has been notified about the booking request, so sit back and relax.",
"OrderDetailsPanel.orderRequestedTitle": "You have requested to book {title}.",
"OrderPage.loadingData": "Loading order data.",
"OrderPage.title": "Order details for ${title}.",
"PageLayout.authInfoFailed": "Could not get authentication information.",
"PageLayout.logoutFailed": "Logout failed. Please try again.",
"PaginationLinks.previous": "Previous page",
"PaginationLinks.next": "Next page",
"PaginationLinks.previous": "Previous page",
"SaleDetailsPanel.listingAcceptedTitle": "{customerName} has booked {title}.",
"SaleDetailsPanel.listingRejectedTitle": "{customerName} requested to book {title}.",
"SaleDetailsPanel.listingRequestedTitle": "{customerName} has requested to book {title}.",
@ -75,8 +80,8 @@
"SaleDetailsPanel.saleRejectedStatus": "You rejected the booking.",
"SaleDetailsPanel.saleRequestedStatus": "{customerName} is waiting for your response.",
"SalePage.acceptButton": "Accept",
"SalePage.rejectButton": "Reject",
"SalePage.loadingData": "Loading sale data.",
"SalePage.rejectButton": "Reject",
"SalePage.title": "Sale details for ${title}.",
"SearchPage.foundResults": "{count, number} {count, plural, one {listing} other {listings}} found.",
"SearchPage.loadingResults": "Loading search results...",