#48: add/remove accounts from account switcher. Allow authorized users to log in into another account

This commit is contained in:
SleepWalker 2016-11-12 22:31:44 +02:00
parent 5c9a1bc953
commit 586cdfffe4
17 changed files with 243 additions and 140 deletions

View File

@ -10,20 +10,13 @@ import { Button } from 'components/ui/form';
import styles from './accountSwitcher.scss'; import styles from './accountSwitcher.scss';
import messages from './AccountSwitcher.intl.json'; import messages from './AccountSwitcher.intl.json';
const accounts = { export class AccountSwitcher extends Component {
active: {id: 7, username: 'SleepWalker', email: 'danilenkos@auroraglobal.com'},
available: [
{id: 7, username: 'SleepWalker', email: 'danilenkos@auroraglobal.com'},
{id: 8, username: 'ErickSkrauch', email: 'erickskrauch@yandex.ru'},
{id: 9, username: 'Ely-en', email: 'ely-en@ely.by'},
{id: 10, username: 'Ely-by', email: 'ely-pt@ely.by'},
]
};
export default class AccountSwitcher extends Component {
static displayName = 'AccountSwitcher'; static displayName = 'AccountSwitcher';
static propTypes = { static propTypes = {
switchAccount: PropTypes.func.isRequired,
removeAccount: PropTypes.func.isRequired,
onAfterAction: PropTypes.func, // called after each action performed
accounts: PropTypes.shape({ // TODO: accounts shape accounts: PropTypes.shape({ // TODO: accounts shape
active: PropTypes.shape({ active: PropTypes.shape({
id: PropTypes.number id: PropTypes.number
@ -43,7 +36,7 @@ export default class AccountSwitcher extends Component {
highlightActiveAccount: true, highlightActiveAccount: true,
allowLogout: true, allowLogout: true,
allowAdd: true, allowAdd: true,
accounts onAfterAction() {}
}; };
render() { render() {
@ -66,7 +59,7 @@ export default class AccountSwitcher extends Component {
styles.accountIcon, styles.accountIcon,
styles.activeAccountIcon, styles.activeAccountIcon,
styles.accountIcon1 styles.accountIcon1
)}></div> )} />
<div className={styles.activeAccountInfo}> <div className={styles.activeAccountInfo}>
<div className={styles.activeAccountUsername}> <div className={styles.activeAccountUsername}>
{accounts.active.username} {accounts.active.username}
@ -81,7 +74,7 @@ export default class AccountSwitcher extends Component {
</a> </a>
</div> </div>
<div className={styles.link}> <div className={styles.link}>
<a href="" className={styles.link}> <a className={styles.link} onClick={this.onRemove(accounts.active)} href="#">
<Message {...messages.logout} /> <Message {...messages.logout} />
</a> </a>
</div> </div>
@ -90,16 +83,19 @@ export default class AccountSwitcher extends Component {
</div> </div>
) : null} ) : null}
{available.map((account, id) => ( {available.map((account, id) => (
<div className={classNames(styles.item, styles.accountSwitchItem)} key={account.id}> <div className={classNames(styles.item, styles.accountSwitchItem)}
key={account.id}
onClick={this.onSwitch(account)}
>
<div className={classNames( <div className={classNames(
styles.accountIcon, styles.accountIcon,
styles[`accountIcon${id % 7 + (highlightActiveAccount ? 2 : 1)}`] styles[`accountIcon${id % 7 + (highlightActiveAccount ? 2 : 1)}`]
)}></div> )} />
{allowLogout ? ( {allowLogout ? (
<div className={styles.logoutIcon}></div> <div className={styles.logoutIcon} onClick={this.onRemove(account)} />
) : ( ) : (
<div className={styles.nextIcon}></div> <div className={styles.nextIcon} />
)} )}
<div className={styles.accountInfo}> <div className={styles.accountInfo}>
@ -113,7 +109,7 @@ export default class AccountSwitcher extends Component {
</div> </div>
))} ))}
{allowAdd ? ( {allowAdd ? (
<Link to="/login"> <Link to="/login" onClick={this.props.onAfterAction}>
<Button <Button
color={COLOR_WHITE} color={COLOR_WHITE}
block block
@ -135,5 +131,29 @@ export default class AccountSwitcher extends Component {
</div> </div>
); );
} }
onSwitch = (account) => (event) => {
event.preventDefault();
this.props.switchAccount(account)
.then(() => this.props.onAfterAction());
};
onRemove = (account) => (event) => {
event.preventDefault();
event.stopPropagation();
this.props.removeAccount(account)
.then(() => this.props.onAfterAction());
};
} }
import { connect } from 'react-redux';
import { authenticate, revoke } from 'components/accounts/actions';
export default connect(({accounts}) => ({
accounts
}), {
switchAccount: authenticate,
removeAccount: revoke
})(AccountSwitcher);

View File

@ -55,10 +55,11 @@ export function authenticate({token, refreshToken}) {
*/ */
export function revoke(account) { export function revoke(account) {
return (dispatch, getState) => { return (dispatch, getState) => {
dispatch(remove(account)); const accountToReplace = getState().accounts.available.find(({id}) => id !== account.id);
if (getState().accounts.length) { if (accountToReplace) {
return dispatch(authenticate(getState().accounts[0])); return dispatch(authenticate(accountToReplace))
.then(() => dispatch(remove(account)));
} }
return dispatch(logout()); return dispatch(logout());

View File

@ -26,9 +26,9 @@ export default function accounts(
throw new Error('Invalid or empty payload passed for accounts.add'); throw new Error('Invalid or empty payload passed for accounts.add');
} }
if (!state.available.some((account) => account.id === payload.id)) { state.available = state.available
state.available = state.available.concat(payload); .filter((account) => account.id !== payload.id)
} .concat(payload);
return state; return state;

View File

@ -446,12 +446,28 @@ class PanelTransition extends Component {
} }
} }
export default connect((state) => ({ export default connect((state) => {
user: state.user, const {login} = state.auth;
auth: state.auth, const user = {
resolve: authFlow.resolve.bind(authFlow), ...state.user,
reject: authFlow.reject.bind(authFlow) isGuest: true,
}), { email: '',
username: ''
};
if (/[@.]/.test(login)) {
user.email = login;
} else {
user.username = login;
}
return {
user,
auth: state.auth,
resolve: authFlow.resolve.bind(authFlow),
reject: authFlow.reject.bind(authFlow)
};
}, {
clearErrors: actions.clearErrors, clearErrors: actions.clearErrors,
setErrors: actions.setErrors setErrors: actions.setErrors
})(PanelTransition); })(PanelTransition);

View File

@ -20,19 +20,7 @@ export function login({login = '', password = '', rememberMe = false}) {
.catch((resp) => { .catch((resp) => {
if (resp.errors) { if (resp.errors) {
if (resp.errors.password === PASSWORD_REQUIRED) { if (resp.errors.password === PASSWORD_REQUIRED) {
let username = ''; return dispatch(setLogin(login));
let email = '';
if (/[@.]/.test(login)) {
email = login;
} else {
username = login;
}
return dispatch(updateUser({
username,
email
}));
} else if (resp.errors.login === ACTIVATION_REQUIRED) { } else if (resp.errors.login === ACTIVATION_REQUIRED) {
return dispatch(needActivation()); return dispatch(needActivation());
} else if (resp.errors.login === LOGIN_REQUIRED && password) { } else if (resp.errors.login === LOGIN_REQUIRED && password) {
@ -126,7 +114,15 @@ export function resendActivation({email = '', captcha}) {
); );
} }
export const ERROR = 'error'; export const SET_LOGIN = 'auth:setLogin';
export function setLogin(login) {
return {
type: SET_LOGIN,
payload: login
};
}
export const ERROR = 'auth:error';
export function setErrors(errors) { export function setErrors(errors) {
return { return {
type: ERROR, type: ERROR,
@ -309,7 +305,11 @@ function authHandler(dispatch) {
return (resp) => dispatch(authenticate({ return (resp) => dispatch(authenticate({
token: resp.access_token, token: resp.access_token,
refreshToken: resp.refresh_token refreshToken: resp.refresh_token
})); })).then((resp) => {
dispatch(setLogin(null));
return resp;
});
} }
function validationErrorsHandler(dispatch, repeatUrl) { function validationErrorsHandler(dispatch, repeatUrl) {

View File

@ -1,8 +1,18 @@
import { combineReducers } from 'redux'; import { combineReducers } from 'redux';
import { ERROR, SET_CLIENT, SET_OAUTH, SET_OAUTH_RESULT, SET_SCOPES, SET_LOADING_STATE, REQUIRE_PERMISSIONS_ACCEPT } from './actions'; import {
ERROR,
SET_CLIENT,
SET_OAUTH,
SET_OAUTH_RESULT,
SET_SCOPES,
SET_LOADING_STATE,
REQUIRE_PERMISSIONS_ACCEPT,
SET_LOGIN
} from './actions';
export default combineReducers({ export default combineReducers({
login,
error, error,
isLoading, isLoading,
client, client,
@ -19,6 +29,24 @@ function error(
if (!error) { if (!error) {
throw new Error('Expected payload with error'); throw new Error('Expected payload with error');
} }
return payload;
default:
return state;
}
}
function login(
state = null,
{type, payload = null}
) {
switch (type) {
case SET_LOGIN:
if (payload !== null && typeof payload !== 'string') {
throw new Error('Expected payload with login string or null');
}
return payload; return payload;
default: default:

View File

@ -44,18 +44,16 @@ export default class LoggedInPanel extends Component {
</button> </button>
<div className={classNames(styles.accountSwitcherContainer)}> <div className={classNames(styles.accountSwitcherContainer)}>
<AccountSwitcher skin="light" /> <AccountSwitcher skin="light" onAfterAction={this.toggleAccountSwitcher} />
</div> </div>
</div> </div>
</div> </div>
); );
} }
toggleAccountSwitcher() { toggleAccountSwitcher = () => this.setState({
this.setState({ isAccountSwitcherActive: !this.state.isAccountSwitcherActive
isAccountSwitcherActive: !this.state.isAccountSwitcherActive });
});
}
onExpandAccountSwitcher = (event) => { onExpandAccountSwitcher = (event) => {
event.preventDefault(); event.preventDefault();

View File

@ -3,11 +3,15 @@ import PasswordState from './PasswordState';
export default class LoginState extends AbstractState { export default class LoginState extends AbstractState {
enter(context) { enter(context) {
const {user} = context.getState(); const {auth, user} = context.getState();
if (user.email || user.username) { // TODO: it may not allow user to leave password state till he click back or enters password
if (auth.login) {
context.setState(new PasswordState()); context.setState(new PasswordState());
} else { } else if (user.isGuest
// for the case, when user is logged in and wants to add a new aacount
|| /login|password/.test(context.getRequest().path) // TODO: improve me
) {
context.navigate('/login'); context.navigate('/login');
} }
} }

View File

@ -5,9 +5,9 @@ import LoginState from './LoginState';
export default class PasswordState extends AbstractState { export default class PasswordState extends AbstractState {
enter(context) { enter(context) {
const {user} = context.getState(); const {auth} = context.getState();
if (user.isGuest) { if (auth.login) {
context.navigate('/password'); context.navigate('/password');
} else { } else {
context.setState(new CompleteState()); context.setState(new CompleteState());
@ -15,12 +15,12 @@ export default class PasswordState extends AbstractState {
} }
resolve(context, {password, rememberMe}) { resolve(context, {password, rememberMe}) {
const {user} = context.getState(); const {auth: {login}} = context.getState();
context.run('login', { context.run('login', {
password, password,
rememberMe, rememberMe,
login: user.email || user.username login
}) })
.then(() => context.setState(new CompleteState())); .then(() => context.setState(new CompleteState()));
} }
@ -30,7 +30,7 @@ export default class PasswordState extends AbstractState {
} }
goBack(context) { goBack(context) {
context.run('logout'); context.run('setLogin', null);
context.setState(new LoginState()); context.setState(new LoginState());
} }
} }

View File

@ -22,7 +22,7 @@ const user = {
lang: 'be' lang: 'be'
}; };
describe('Accounts actions', () => { describe('components/accounts/actions', () => {
let dispatch; let dispatch;
let getState; let getState;
@ -109,19 +109,15 @@ describe('Accounts actions', () => {
}); });
describe('#revoke()', () => { describe('#revoke()', () => {
it(`should dispatch ${REMOVE} action`, () => {
revoke(account)(dispatch, getState);
expect(dispatch, 'to have a call satisfying', [
remove(account)
]);
});
it('should switch next account if available', () => { it('should switch next account if available', () => {
const account2 = {...account, id: 2}; const account2 = {...account, id: 2};
getState.returns({ getState.returns({
accounts: [account] accounts: {
active: account2,
available: [account]
},
user
}); });
return revoke(account2)(dispatch, getState).then(() => { return revoke(account2)(dispatch, getState).then(() => {
@ -143,10 +139,15 @@ describe('Accounts actions', () => {
}); });
it('should logout if no other accounts available', () => { it('should logout if no other accounts available', () => {
getState.returns({
accounts: {
active: account,
available: []
},
user
});
revoke(account)(dispatch, getState).then(() => { revoke(account)(dispatch, getState).then(() => {
expect(dispatch, 'to have a call satisfying', [
remove(account)
]);
expect(dispatch, 'to have a call satisfying', [ expect(dispatch, 'to have a call satisfying', [
{payload: {isGuest: true}} {payload: {isGuest: true}}
// updateUser({isGuest: true}) // updateUser({isGuest: true})

View File

@ -45,11 +45,23 @@ describe('Accounts reducer', () => {
}) })
); );
it('should not add the same account twice', () => it('should replace if account was added for the second time', () => {
expect(accounts({...initial, available: [account]}, add(account)), 'to satisfy', { const outdatedAccount = {
available: [account] ...account,
}) someShit: true
); };
const updatedAccount = {
...account,
token: 'newToken'
};
return expect(
accounts({...initial, available: [outdatedAccount]}, add(updatedAccount)),
'to satisfy', {
available: [updatedAccount]
});
});
it('throws, when account is invalid', () => { it('throws, when account is invalid', () => {
expect(() => accounts(initial, add()), expect(() => accounts(initial, add()),

View File

@ -10,7 +10,9 @@ import {
setOAuthRequest, setOAuthRequest,
setScopes, setScopes,
setOAuthCode, setOAuthCode,
requirePermissionsAccept requirePermissionsAccept,
login,
setLogin
} from 'components/auth/actions'; } from 'components/auth/actions';
const oauthData = { const oauthData = {
@ -22,8 +24,8 @@ const oauthData = {
}; };
describe('components/auth/actions', () => { describe('components/auth/actions', () => {
const dispatch = sinon.stub().named('dispatch'); const dispatch = sinon.stub().named('store.dispatch');
const getState = sinon.stub().named('getState'); const getState = sinon.stub().named('store.getState');
function callThunk(fn, ...args) { function callThunk(fn, ...args) {
const thunk = fn(...args); const thunk = fn(...args);
@ -67,21 +69,21 @@ describe('components/auth/actions', () => {
request.get.returns(Promise.resolve(resp)); request.get.returns(Promise.resolve(resp));
}); });
it('should send get request to an api', () => { it('should send get request to an api', () =>
return callThunk(oAuthValidate, oauthData).then(() => { callThunk(oAuthValidate, oauthData).then(() => {
expect(request.get, 'to have a call satisfying', ['/api/oauth2/v1/validate', {}]); expect(request.get, 'to have a call satisfying', ['/api/oauth2/v1/validate', {}]);
}); })
}); );
it('should dispatch setClient, setOAuthRequest and setScopes', () => { it('should dispatch setClient, setOAuthRequest and setScopes', () =>
return callThunk(oAuthValidate, oauthData).then(() => { callThunk(oAuthValidate, oauthData).then(() => {
expectDispatchCalls([ expectDispatchCalls([
[setClient(resp.client)], [setClient(resp.client)],
[setOAuthRequest(resp.oAuth)], [setOAuthRequest(resp.oAuth)],
[setScopes(resp.session.scopes)] [setScopes(resp.session.scopes)]
]); ]);
}); })
}); );
}); });
describe('#oAuthComplete()', () => { describe('#oAuthComplete()', () => {
@ -160,4 +162,24 @@ describe('components/auth/actions', () => {
}); });
}); });
}); });
describe('#login()', () => {
describe('when correct login was entered', () => {
beforeEach(() => {
request.post.returns(Promise.reject({
errors: {
password: 'error.password_required'
}
}));
});
it('should set login', () =>
callThunk(login, {login: 'foo'}).then(() => {
expectDispatchCalls([
[setLogin('foo')]
]);
})
);
});
});
}); });

View File

@ -0,0 +1,16 @@
import expect from 'unexpected';
import auth from 'components/auth/reducer';
import { setLogin, SET_LOGIN } from 'components/auth/actions';
describe('auth reducer', () => {
describe(SET_LOGIN, () => {
it('should set login', () => {
const expectedLogin = 'foo';
expect(auth(undefined, setLogin(expectedLogin)), 'to satisfy', {
login: expectedLogin
});
});
});
});

View File

@ -68,7 +68,7 @@ describe('refreshTokenMiddleware', () => {
}); });
it('should not apply to refresh-token request', () => { it('should not apply to refresh-token request', () => {
const data = {url: '/refresh-token'}; const data = {url: '/refresh-token', options: {}};
const resp = middleware.before(data); const resp = middleware.before(data);
expect(resp, 'to satisfy', data); expect(resp, 'to satisfy', data);

View File

@ -47,6 +47,9 @@ describe('AuthFlow.functional', () => {
state.user = { state.user = {
isGuest: true isGuest: true
}; };
state.auth = {
login: null
};
}); });
it('should redirect guest / -> /login', () => { it('should redirect guest / -> /login', () => {

View File

@ -1,6 +1,5 @@
import LoginState from 'services/authFlow/LoginState'; import LoginState from 'services/authFlow/LoginState';
import PasswordState from 'services/authFlow/PasswordState'; import PasswordState from 'services/authFlow/PasswordState';
import ForgotPasswordState from 'services/authFlow/ForgotPasswordState';
import { bootstrap, expectState, expectNavigate, expectRun } from './helpers'; import { bootstrap, expectState, expectNavigate, expectRun } from './helpers';
@ -24,7 +23,8 @@ describe('LoginState', () => {
describe('#enter', () => { describe('#enter', () => {
it('should navigate to /login', () => { it('should navigate to /login', () => {
context.getState.returns({ context.getState.returns({
user: {isGuest: true} user: {isGuest: true},
auth: {login: null}
}); });
expectNavigate(mock, '/login'); expectNavigate(mock, '/login');
@ -32,22 +32,15 @@ describe('LoginState', () => {
state.enter(context); state.enter(context);
}); });
const testTransitionToPassword = (user) => { it('should transition to password if login was set', () => {
context.getState.returns({ context.getState.returns({
user: user user: {isGuest: true},
auth: {login: 'foo'}
}); });
expectState(mock, PasswordState); expectState(mock, PasswordState);
state.enter(context); state.enter(context);
};
it('should transition to password if has email', () => {
testTransitionToPassword({email: 'foo'});
});
it('should transition to password if has username', () => {
testTransitionToPassword({username: 'foo'});
}); });
}); });

View File

@ -25,7 +25,8 @@ describe('PasswordState', () => {
describe('#enter', () => { describe('#enter', () => {
it('should navigate to /password', () => { it('should navigate to /password', () => {
context.getState.returns({ context.getState.returns({
user: {isGuest: true} user: {isGuest: true},
auth: {login: 'foo'}
}); });
expectNavigate(mock, '/password'); expectNavigate(mock, '/password');
@ -35,7 +36,8 @@ describe('PasswordState', () => {
it('should transition to complete if not guest', () => { it('should transition to complete if not guest', () => {
context.getState.returns({ context.getState.returns({
user: {isGuest: false} user: {isGuest: false},
auth: {login: null}
}); });
expectState(mock, CompleteState); expectState(mock, CompleteState);
@ -45,42 +47,29 @@ describe('PasswordState', () => {
}); });
describe('#resolve', () => { describe('#resolve', () => {
(function() { it('should call login with login and password', () => {
const expectedLogin = 'login'; const expectedLogin = 'foo';
const expectedPassword = 'password'; const expectedPassword = 'bar';
const expectedRememberMe = true; const expectedRememberMe = true;
const testWith = (user) => { context.getState.returns({
it(`should call login with email or username and password. User: ${JSON.stringify(user)}`, () => { auth: {
context.getState.returns({user}); login: expectedLogin
}
expectRun(
mock,
'login',
sinon.match({
login: expectedLogin,
password: expectedPassword,
rememberMe: expectedRememberMe,
})
).returns({then() {}});
state.resolve(context, {password: expectedPassword, rememberMe: expectedRememberMe});
});
};
testWith({
email: expectedLogin
}); });
testWith({ expectRun(
username: expectedLogin mock,
}); 'login',
sinon.match({
login: expectedLogin,
password: expectedPassword,
rememberMe: expectedRememberMe,
})
).returns({then() {}});
testWith({ state.resolve(context, {password: expectedPassword, rememberMe: expectedRememberMe});
email: expectedLogin, });
username: expectedLogin
});
}());
it('should transition to complete state on successfull login', () => { it('should transition to complete state on successfull login', () => {
const promise = Promise.resolve(); const promise = Promise.resolve();
@ -88,8 +77,8 @@ describe('PasswordState', () => {
const expectedPassword = 'password'; const expectedPassword = 'password';
context.getState.returns({ context.getState.returns({
user: { auth: {
email: expectedLogin login: expectedLogin
} }
}); });
@ -111,8 +100,8 @@ describe('PasswordState', () => {
}); });
describe('#goBack', () => { describe('#goBack', () => {
it('should transition to forgot password state', () => { it('should transition to login state', () => {
expectRun(mock, 'logout'); expectRun(mock, 'setLogin', null);
expectState(mock, LoginState); expectState(mock, LoginState);
state.goBack(context); state.goBack(context);