diff --git a/src/components/accounts/actions.js b/src/components/accounts/actions.js index a088aac..e60c0e1 100644 --- a/src/components/accounts/actions.js +++ b/src/components/accounts/actions.js @@ -3,8 +3,19 @@ import { routeActions } from 'react-router-redux'; import authentication from 'services/api/authentication'; import { updateUser, setGuest } from 'components/user/actions'; import { setLocale } from 'components/i18n/actions'; +import { setAccountSwitcher } from 'components/auth/actions'; import logger from 'services/logger'; +import { + add, + remove, + activate, + reset, + updateToken +} from 'components/accounts/actions/pure-actions'; + +export { updateToken }; + /** * @typedef {object} Account * @property {string} id @@ -22,7 +33,7 @@ import logger from 'services/logger'; * @return {function} */ export function authenticate({token, refreshToken}) { - return (dispatch) => + return (dispatch, getState) => authentication.validateToken({token, refreshToken}) .catch((resp) => { logger.warn('Error validating token during auth', { @@ -46,6 +57,8 @@ export function authenticate({token, refreshToken}) { } })) .then(({user, account}) => { + const {auth} = getState(); + dispatch(add(account)); dispatch(activate(account)); dispatch(updateUser(user)); @@ -58,12 +71,22 @@ export function authenticate({token, refreshToken}) { sessionStorage.setItem(`stranger${account.id}`, 1); } + if (auth && auth.oauth && auth.oauth.clientId) { + // if we authenticating during oauth, we disable account chooser + // because user probably has made his choise now + // this may happen, when user registers, logs in or uses account + // chooser panel during oauth + dispatch(setAccountSwitcher(false)); + } + return dispatch(setLocale(user.lang)) .then(() => account); }); } /** + * Remove one account from current user's account list + * * @param {Account} account * * @return {function} @@ -135,73 +158,3 @@ export function logoutStrangers() { return Promise.resolve(); }; } - -export const ADD = 'accounts:add'; -/** - * @api private - * - * @param {Account} account - * - * @return {object} - action definition - */ -export function add(account) { - return { - type: ADD, - payload: account - }; -} - -export const REMOVE = 'accounts:remove'; -/** - * @api private - * - * @param {Account} account - * - * @return {object} - action definition - */ -export function remove(account) { - return { - type: REMOVE, - payload: account - }; -} - -export const ACTIVATE = 'accounts:activate'; -/** - * @api private - * - * @param {Account} account - * - * @return {object} - action definition - */ -export function activate(account) { - return { - type: ACTIVATE, - payload: account - }; -} - -export const RESET = 'accounts:reset'; -/** - * @api private - * - * @return {object} - action definition - */ -export function reset() { - return { - type: RESET - }; -} - -export const UPDATE_TOKEN = 'accounts:updateToken'; -/** - * @param {string} token - * - * @return {object} - action definition - */ -export function updateToken(token) { - return { - type: UPDATE_TOKEN, - payload: token - }; -} diff --git a/src/components/accounts/actions/pure-actions.js b/src/components/accounts/actions/pure-actions.js new file mode 100644 index 0000000..4946b95 --- /dev/null +++ b/src/components/accounts/actions/pure-actions.js @@ -0,0 +1,69 @@ +export const ADD = 'accounts:add'; +/** + * @api private + * + * @param {Account} account + * + * @return {object} - action definition + */ +export function add(account) { + return { + type: ADD, + payload: account + }; +} + +export const REMOVE = 'accounts:remove'; +/** + * @api private + * + * @param {Account} account + * + * @return {object} - action definition + */ +export function remove(account) { + return { + type: REMOVE, + payload: account + }; +} + +export const ACTIVATE = 'accounts:activate'; +/** + * @api private + * + * @param {Account} account + * + * @return {object} - action definition + */ +export function activate(account) { + return { + type: ACTIVATE, + payload: account + }; +} + +export const RESET = 'accounts:reset'; +/** + * @api private + * + * @return {object} - action definition + */ +export function reset() { + return { + type: RESET + }; +} + +export const UPDATE_TOKEN = 'accounts:updateToken'; +/** + * @param {string} token + * + * @return {object} - action definition + */ +export function updateToken(token) { + return { + type: UPDATE_TOKEN, + payload: token + }; +} diff --git a/src/components/accounts/reducer.js b/src/components/accounts/reducer.js index 173a895..d712eeb 100644 --- a/src/components/accounts/reducer.js +++ b/src/components/accounts/reducer.js @@ -1,4 +1,4 @@ -import { ADD, REMOVE, ACTIVATE, RESET, UPDATE_TOKEN } from './actions'; +import { ADD, REMOVE, ACTIVATE, RESET, UPDATE_TOKEN } from './actions/pure-actions'; /** * @typedef {AccountsState} diff --git a/src/components/auth/PanelTransition.jsx b/src/components/auth/PanelTransition.jsx index f493455..316a7d3 100644 --- a/src/components/auth/PanelTransition.jsx +++ b/src/components/auth/PanelTransition.jsx @@ -67,6 +67,9 @@ class PanelTransition extends Component { login: PropTypes.string }).isRequired, user: userShape.isRequired, + accounts: PropTypes.shape({ + available: PropTypes.array + }), setErrors: PropTypes.func.isRequired, clearErrors: PropTypes.func.isRequired, resolve: PropTypes.func.isRequired, @@ -320,9 +323,15 @@ class PanelTransition extends Component { } getHeader({key, style, data}) { - const {Title, hasBackButton} = data; + const {Title} = data; const {transformSpring} = style; + let {hasBackButton} = data; + + if (typeof hasBackButton === 'function') { + hasBackButton = hasBackButton(this.props); + } + style = { ...this.getDefaultTransitionStyles(key, style), opacity: 1 // reset default diff --git a/src/components/auth/actions.js b/src/components/auth/actions.js index e5a47af..427f442 100644 --- a/src/components/auth/actions.js +++ b/src/components/auth/actions.js @@ -1,6 +1,7 @@ import { routeActions } from 'react-router-redux'; import logger from 'services/logger'; +import history from 'services/history'; import { updateUser, acceptRules as userAcceptRules } from 'components/user/actions'; import { authenticate, logoutAll } from 'components/accounts/actions'; import authentication from 'services/api/authentication'; @@ -11,6 +12,25 @@ import dispatchBsod from 'components/ui/bsod/dispatchBsod'; export { updateUser } from 'components/user/actions'; export { authenticate, logoutAll as logout } from 'components/accounts/actions'; +/** + * Reoutes user to the previous page if it is possible + * + * @param {string} fallbackUrl - an url to route user to if goBack is not possible + * + * @return {object} - action definition + */ +export function goBack(fallbackUrl = null) { + if (history.canGoBack()) { + return routeActions.goBack(); + } else if (fallbackUrl) { + return routeActions.push(fallbackUrl); + } + + return { + type: 'noop' + }; +} + export function login({login = '', password = '', rememberMe = false}) { const PASSWORD_REQUIRED = 'error.password_required'; const LOGIN_REQUIRED = 'error.login_required'; diff --git a/src/components/auth/chooseAccount/ChooseAccount.intl.json b/src/components/auth/chooseAccount/ChooseAccount.intl.json index 9b207ca..d614678 100644 --- a/src/components/auth/chooseAccount/ChooseAccount.intl.json +++ b/src/components/auth/chooseAccount/ChooseAccount.intl.json @@ -2,5 +2,6 @@ "chooseAccountTitle": "Choose an account", "addAccount": "Log into another account", "logoutAll": "Log out from all accounts", + "createNewAccount": "Create new account", "description": "You have logged in into multiple accounts. Please choose the one, you want to use to authorize {appName}" } diff --git a/src/components/auth/chooseAccount/ChooseAccount.jsx b/src/components/auth/chooseAccount/ChooseAccount.jsx index 0aaa6de..39af5e3 100644 --- a/src/components/auth/chooseAccount/ChooseAccount.jsx +++ b/src/components/auth/chooseAccount/ChooseAccount.jsx @@ -10,7 +10,11 @@ export default factory({ }, links: [ { - label: messages.logoutAll + label: messages.createNewAccount + }, + { + label: messages.logoutAll, + payload: {logout: true} } ] }); diff --git a/src/components/auth/login/LoginBody.jsx b/src/components/auth/login/LoginBody.jsx index a5de8a9..c6b1171 100644 --- a/src/components/auth/login/LoginBody.jsx +++ b/src/components/auth/login/LoginBody.jsx @@ -1,5 +1,3 @@ -import React from 'react'; - import { Input } from 'components/ui/form'; import BaseAuthBody from 'components/auth/BaseAuthBody'; @@ -8,6 +6,9 @@ import messages from './Login.intl.json'; export default class LoginBody extends BaseAuthBody { static displayName = 'LoginBody'; static panelId = 'login'; + static hasGoBack = (state) => { + return !state.user.isGuest; + }; autoFocusField = 'login'; diff --git a/src/components/profile/ProfileForm.jsx b/src/components/profile/ProfileForm.jsx index dc2c18b..e39452d 100644 --- a/src/components/profile/ProfileForm.jsx +++ b/src/components/profile/ProfileForm.jsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React from 'react'; import { FormattedMessage as Message } from 'react-intl'; import { Link } from 'react-router'; diff --git a/src/components/profile/changePassword/ChangePassword.jsx b/src/components/profile/changePassword/ChangePassword.jsx index c50f84b..b40b1fd 100644 --- a/src/components/profile/changePassword/ChangePassword.jsx +++ b/src/components/profile/changePassword/ChangePassword.jsx @@ -1,7 +1,6 @@ import React, { Component, PropTypes } from 'react'; import { FormattedMessage as Message } from 'react-intl'; -import { Link } from 'react-router'; import Helmet from 'react-helmet'; import { Input, Button, Checkbox, Form, FormModel } from 'components/ui/form'; diff --git a/src/index.js b/src/index.js index 5dfe827..14b7748 100644 --- a/src/index.js +++ b/src/index.js @@ -14,6 +14,9 @@ import bsodFactory from 'components/ui/bsod/factory'; import loader from 'services/loader'; import logger from 'services/logger'; import font from 'services/font'; +import history from 'services/history'; + +history.init(); logger.init({ sentryCdn: window.SENTRY_CDN diff --git a/src/services/authFlow/ChooseAccountState.js b/src/services/authFlow/ChooseAccountState.js index ffa57b6..92de6b8 100644 --- a/src/services/authFlow/ChooseAccountState.js +++ b/src/services/authFlow/ChooseAccountState.js @@ -1,6 +1,7 @@ import AbstractState from './AbstractState'; import LoginState from './LoginState'; import CompleteState from './CompleteState'; +import RegisterState from './RegisterState'; export default class ChooseAccountState extends AbstractState { enter(context) { @@ -8,9 +9,6 @@ export default class ChooseAccountState extends AbstractState { } resolve(context, payload) { - // do not ask again after user adds account, or chooses an existed one - context.run('setAccountSwitcher', false); - if (payload.id) { context.setState(new CompleteState()); } else { @@ -19,7 +17,16 @@ export default class ChooseAccountState extends AbstractState { } } - reject(context) { - context.run('logout'); + /** + * @param {object} context + * @param {object} payload + * @param {bool} [payload.logout=false] + */ + reject(context, payload = {}) { + if (payload.logout) { + context.run('logout'); + } else { + context.setState(new RegisterState()); + } } } diff --git a/src/services/authFlow/CompleteState.js b/src/services/authFlow/CompleteState.js index 00ac4f2..15ae37e 100644 --- a/src/services/authFlow/CompleteState.js +++ b/src/services/authFlow/CompleteState.js @@ -17,7 +17,7 @@ export default class CompleteState extends AbstractState { } enter(context) { - const {auth = {}, user, accounts} = context.getState(); + const {auth = {}, user} = context.getState(); if (user.isGuest) { context.setState(new LoginState()); @@ -26,67 +26,75 @@ export default class CompleteState extends AbstractState { } else if (user.shouldAcceptRules) { context.setState(new AcceptRulesState()); } else if (auth.oauth && auth.oauth.clientId) { - let isSwitcherEnabled = auth.isSwitcherEnabled; - - if (auth.oauth.loginHint) { - const account = accounts.available.filter((account) => - account.id === auth.oauth.loginHint * 1 - || account.email === auth.oauth.loginHint - || account.username === auth.oauth.loginHint - )[0]; - - if (account) { - // disable switching, because we are know the account, user must be authorized with - context.run('setAccountSwitcher', false); - isSwitcherEnabled = false; - - if (account.id !== accounts.active.id) { - // lets switch user to an account, that is needed for auth - return context.run('authenticate', account) - .then(() => context.setState(new CompleteState())); - } - } - } - - if (isSwitcherEnabled - && (accounts.available.length > 1 - || auth.oauth.prompt.includes(PROMPT_ACCOUNT_CHOOSE) - ) - ) { - context.setState(new ChooseAccountState()); - } else if (auth.oauth.code) { - context.setState(new FinishState()); - } else { - const data = {}; - if (typeof this.isPermissionsAccepted !== 'undefined') { - data.accept = this.isPermissionsAccepted; - } else if (auth.oauth.acceptRequired || auth.oauth.prompt.includes(PROMPT_PERMISSIONS)) { - context.setState(new PermissionsState()); - return; - } - // TODO: it seams that oAuthComplete may be a separate state - return context.run('oAuthComplete', data).then((resp) => { - // TODO: пусть в стейт попадает флаг или тип авторизации - // вместо волшебства над редирект урлой - if (resp.redirectUri.indexOf('static_page') === 0) { - context.setState(new FinishState()); - } else { - return new Promise(() => { - // do not resolve promise to make loader visible and - // overcome app rendering - context.run('redirect', resp.redirectUri); - }); - } - }, (resp) => { - if (resp.unauthorized) { - context.setState(new LoginState()); - } else if (resp.acceptRequired) { - context.setState(new PermissionsState()); - } - }); - } + return this.processOAuth(context); } else { context.navigate('/'); } } + + processOAuth(context) { + const {auth = {}, accounts} = context.getState(); + + let isSwitcherEnabled = auth.isSwitcherEnabled; + const loginHint = auth.oauth.loginHint; + + if (loginHint) { + const account = accounts.available.filter((account) => + account.id === loginHint * 1 + || account.email === loginHint + || account.username === loginHint + )[0]; + + if (account) { + // disable switching, because we are know the account, user must be authorized with + context.run('setAccountSwitcher', false); + isSwitcherEnabled = false; + + if (account.id !== accounts.active.id) { + // lets switch user to an account, that is needed for auth + return context.run('authenticate', account) + .then(() => context.setState(new CompleteState())); + } + } + } + + if (isSwitcherEnabled + && (accounts.available.length > 1 + || auth.oauth.prompt.includes(PROMPT_ACCOUNT_CHOOSE) + ) + ) { + context.setState(new ChooseAccountState()); + } else if (auth.oauth.code) { + context.setState(new FinishState()); + } else { + const data = {}; + if (typeof this.isPermissionsAccepted !== 'undefined') { + data.accept = this.isPermissionsAccepted; + } else if (auth.oauth.acceptRequired || auth.oauth.prompt.includes(PROMPT_PERMISSIONS)) { + context.setState(new PermissionsState()); + return; + } + + // TODO: it seams that oAuthComplete may be a separate state + return context.run('oAuthComplete', data).then((resp) => { + // TODO: пусть в стейт попадает флаг или тип авторизации + // вместо волшебства над редирект урлой + if (resp.redirectUri.indexOf('static_page') === 0) { + context.setState(new FinishState()); + } else { + return new Promise(() => { + // do not resolve promise to make loader visible and + // overcome app rendering + context.run('redirect', resp.redirectUri); + }); + } + }, (resp) => { + if (resp.unauthorized) { + context.setState(new LoginState()); + } else if (resp.acceptRequired) { + context.setState(new PermissionsState()); + } + }); + } + } } diff --git a/src/services/authFlow/LoginState.js b/src/services/authFlow/LoginState.js index 04161c6..991790c 100644 --- a/src/services/authFlow/LoginState.js +++ b/src/services/authFlow/LoginState.js @@ -7,15 +7,16 @@ export default class LoginState extends AbstractState { enter(context) { const {auth, user} = context.getState(); + const isUserAddsSecondAccount = !user.isGuest + && /login|password/.test(context.getRequest().path); // TODO: improve me + // TODO: it may not allow user to leave password state till he click back or enters password if (auth.login) { context.setState(new PasswordState()); - } 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 - ) { + } else if (user.isGuest || isUserAddsSecondAccount) { context.navigate('/login'); } else { + // can not detect needed state. Delegating decision to the next state context.setState(new PasswordState()); } } @@ -25,4 +26,8 @@ export default class LoginState extends AbstractState { .then(() => context.setState(new PasswordState())) .catch((err = {}) => err.errors || logger.warn(err)); } + + goBack(context) { + context.run('goBack', '/'); + } } diff --git a/src/services/authFlow/PasswordState.js b/src/services/authFlow/PasswordState.js index bdbd9db..f6d056b 100644 --- a/src/services/authFlow/PasswordState.js +++ b/src/services/authFlow/PasswordState.js @@ -19,7 +19,7 @@ export default class PasswordState extends AbstractState { resolve(context, {password, rememberMe}) { const {auth: {login}} = context.getState(); - context.run('login', { + return context.run('login', { password, rememberMe, login diff --git a/src/services/authFlow/RegisterState.js b/src/services/authFlow/RegisterState.js index af0bd4c..1503bbd 100644 --- a/src/services/authFlow/RegisterState.js +++ b/src/services/authFlow/RegisterState.js @@ -7,13 +7,7 @@ import ResendActivationState from './ResendActivationState'; export default class RegisterState extends AbstractState { enter(context) { - const {user} = context.getState(); - - if (user.isGuest) { - context.navigate('/register'); - } else { - context.setState(new CompleteState()); - } + context.navigate('/register'); } resolve(context, payload) { diff --git a/src/services/history.js b/src/services/history.js new file mode 100644 index 0000000..8828b31 --- /dev/null +++ b/src/services/history.js @@ -0,0 +1,17 @@ +/** + * A helper wrapper service around window.history + */ + +export default { + init() { + this.initialLength = window.history.length; + }, + + /** + * @return {bool} - whether history.back() can be safetly called + */ + canGoBack() { + return document.referrer.includes(`${location.protocol}//${location.host}`) + || this.initialLength < window.history.length; + } +}; diff --git a/tests/components/accounts/actions.test.js b/tests/components/accounts/actions.test.js index c8eb9b6..c532db5 100644 --- a/tests/components/accounts/actions.test.js +++ b/tests/components/accounts/actions.test.js @@ -3,21 +3,23 @@ import sinon from 'sinon'; import { routeActions } from 'react-router-redux'; -import accounts from 'services/api/accounts'; import authentication from 'services/api/authentication'; import { authenticate, revoke, - add, ADD, - activate, ACTIVATE, - remove, - reset, logoutAll, logoutStrangers } from 'components/accounts/actions'; +import { + add, ADD, + activate, ACTIVATE, + remove, + reset +} from 'components/accounts/actions/pure-actions'; import { SET_LOCALE } from 'components/i18n/actions'; import { updateUser, setUser } from 'components/user/actions'; +import { setAccountSwitcher } from 'components/auth/actions'; const account = { id: 1, @@ -66,7 +68,7 @@ describe('components/accounts/actions', () => { describe('#authenticate()', () => { it('should request user state using token', () => - authenticate(account)(dispatch).then(() => + authenticate(account)(dispatch, getState).then(() => expect(authentication.validateToken, 'to have a call satisfying', [ {token: account.token, refreshToken: account.refreshToken} ]) @@ -74,7 +76,7 @@ describe('components/accounts/actions', () => { ); it(`dispatches ${ADD} action`, () => - authenticate(account)(dispatch).then(() => + authenticate(account)(dispatch, getState).then(() => expect(dispatch, 'to have a call satisfying', [ add(account) ]) @@ -82,7 +84,7 @@ describe('components/accounts/actions', () => { ); it(`dispatches ${ACTIVATE} action`, () => - authenticate(account)(dispatch).then(() => + authenticate(account)(dispatch, getState).then(() => expect(dispatch, 'to have a call satisfying', [ activate(account) ]) @@ -90,7 +92,7 @@ describe('components/accounts/actions', () => { ); it(`dispatches ${SET_LOCALE} action`, () => - authenticate(account)(dispatch).then(() => + authenticate(account)(dispatch, getState).then(() => expect(dispatch, 'to have a call satisfying', [ {type: SET_LOCALE, payload: {locale: 'be'}} ]) @@ -98,7 +100,7 @@ describe('components/accounts/actions', () => { ); it('should update user state', () => - authenticate(account)(dispatch).then(() => + authenticate(account)(dispatch, getState).then(() => expect(dispatch, 'to have a call satisfying', [ updateUser({...user, isGuest: false}) ]) @@ -106,7 +108,7 @@ describe('components/accounts/actions', () => { ); it('resolves with account', () => - authenticate(account)(dispatch).then((resp) => + authenticate(account)(dispatch, getState).then((resp) => expect(resp, 'to equal', account) ) ); @@ -114,7 +116,7 @@ describe('components/accounts/actions', () => { it('rejects when bad auth data', () => { authentication.validateToken.returns(Promise.reject({})); - return expect(authenticate(account)(dispatch), 'to be rejected').then(() => { + return expect(authenticate(account)(dispatch, getState), 'to be rejected').then(() => { expect(dispatch, 'to have a call satisfying', [ {payload: {isGuest: true}}, ]); @@ -133,11 +135,37 @@ describe('components/accounts/actions', () => { sessionStorage.removeItem(expectedKey); - return authenticate(account)(dispatch).then(() => { + return authenticate(account)(dispatch, getState).then(() => { expect(sessionStorage.getItem(expectedKey), 'not to be null'); sessionStorage.removeItem(expectedKey); }); }); + + describe('when user authenticated during oauth', () => { + beforeEach(() => { + getState.returns({ + accounts: { + available: [], + active: null + }, + user: {}, + auth: { + oauth: { + clientId: 'ely.by', + prompt: [] + } + } + }); + }); + + it('should dispatch setAccountSwitcher', () => + authenticate(account)(dispatch, getState).then(() => + expect(dispatch, 'to have a call satisfying', [ + setAccountSwitcher(false) + ]) + ) + ); + }); }); describe('#revoke()', () => { diff --git a/tests/components/accounts/reducer.test.js b/tests/components/accounts/reducer.test.js index 731860c..b3d21c4 100644 --- a/tests/components/accounts/reducer.test.js +++ b/tests/components/accounts/reducer.test.js @@ -2,9 +2,12 @@ import expect from 'unexpected'; import accounts from 'components/accounts/reducer'; import { - updateToken, add, remove, activate, reset, - ADD, REMOVE, ACTIVATE, UPDATE_TOKEN, RESET + updateToken } from 'components/accounts/actions'; +import { + add, remove, activate, reset, + ADD, REMOVE, ACTIVATE, UPDATE_TOKEN, RESET +} from 'components/accounts/actions/pure-actions'; const account = { id: 1, diff --git a/tests/services/authFlow/ChooseAccountState.test.js b/tests/services/authFlow/ChooseAccountState.test.js index 58f2021..f600eef 100644 --- a/tests/services/authFlow/ChooseAccountState.test.js +++ b/tests/services/authFlow/ChooseAccountState.test.js @@ -1,6 +1,7 @@ import ChooseAccountState from 'services/authFlow/ChooseAccountState'; import CompleteState from 'services/authFlow/CompleteState'; import LoginState from 'services/authFlow/LoginState'; +import RegisterState from 'services/authFlow/RegisterState'; import { bootstrap, expectState, expectNavigate, expectRun } from './helpers'; @@ -31,14 +32,12 @@ describe('ChooseAccountState', () => { describe('#resolve', () => { it('should transition to complete if existed account was choosen', () => { - expectRun(mock, 'setAccountSwitcher', false); expectState(mock, CompleteState); state.resolve(context, {id: 123}); }); it('should transition to login if user wants to add new account', () => { - expectRun(mock, 'setAccountSwitcher', false); expectNavigate(mock, '/login'); expectState(mock, LoginState); @@ -47,10 +46,16 @@ describe('ChooseAccountState', () => { }); describe('#reject', () => { - it('should logout', () => { - expectRun(mock, 'logout'); + it('should transition to register', () => { + expectState(mock, RegisterState); state.reject(context); }); + + it('should logout', () => { + expectRun(mock, 'logout'); + + state.reject(context, {logout: true}); + }); }); }); diff --git a/tests/services/authFlow/CompleteState.test.js b/tests/services/authFlow/CompleteState.test.js index b8149e5..0d1bf4a 100644 --- a/tests/services/authFlow/CompleteState.test.js +++ b/tests/services/authFlow/CompleteState.test.js @@ -1,4 +1,5 @@ import expect from 'unexpected'; +import sinon from 'sinon'; import CompleteState from 'services/authFlow/CompleteState'; import LoginState from 'services/authFlow/LoginState'; @@ -6,6 +7,7 @@ import ActivationState from 'services/authFlow/ActivationState'; import AcceptRulesState from 'services/authFlow/AcceptRulesState'; import FinishState from 'services/authFlow/FinishState'; import PermissionsState from 'services/authFlow/PermissionsState'; +import ChooseAccountState from 'services/authFlow/ChooseAccountState'; import { bootstrap, expectState, expectNavigate, expectRun } from './helpers'; @@ -133,9 +135,144 @@ describe('CompleteState', () => { state.enter(context); }); + + it('should transition to permissions state if prompt=consent', () => { + context.getState.returns({ + user: { + isActive: true, + isGuest: false + }, + auth: { + oauth: { + clientId: 'ely.by', + prompt: ['consent'] + } + } + }); + + expectState(mock, PermissionsState); + + state.enter(context); + }); + + it('should transition to ChooseAccountState if user has multiple accs and switcher enabled', () => { + context.getState.returns({ + user: { + isActive: true, + isGuest: false + }, + accounts: { + available: [ + {id: 1}, + {id: 2} + ], + active: { + id: 1 + } + }, + auth: { + isSwitcherEnabled: true, + oauth: { + clientId: 'ely.by', + prompt: [] + } + } + }); + + expectState(mock, ChooseAccountState); + + state.enter(context); + }); + + it('should NOT transition to ChooseAccountState if user has multiple accs and switcher disabled', () => { + context.getState.returns({ + user: { + isActive: true, + isGuest: false + }, + accounts: { + available: [ + {id: 1}, + {id: 2} + ], + active: { + id: 1 + } + }, + auth: { + isSwitcherEnabled: false, + oauth: { + clientId: 'ely.by', + prompt: [] + } + } + }); + + expectRun(mock, 'oAuthComplete', {}) + .returns({then() {}}); + + state.enter(context); + }); + + it('should transition to ChooseAccountState if prompt=select_account and switcher enabled', () => { + context.getState.returns({ + user: { + isActive: true, + isGuest: false + }, + accounts: { + available: [ + {id: 1} + ], + active: { + id: 1 + } + }, + auth: { + isSwitcherEnabled: true, + oauth: { + clientId: 'ely.by', + prompt: ['select_account'] + } + } + }); + + expectState(mock, ChooseAccountState); + + state.enter(context); + }); + + it('should NOT transition to ChooseAccountState if prompt=select_account and switcher disabled', () => { + context.getState.returns({ + user: { + isActive: true, + isGuest: false + }, + accounts: { + available: [ + {id: 1} + ], + active: { + id: 1 + } + }, + auth: { + isSwitcherEnabled: false, + oauth: { + clientId: 'ely.by', + prompt: ['select_account'] + } + } + }); + + expectRun(mock, 'oAuthComplete', {}) + .returns({then() {}}); + + state.enter(context); + }); }); - describe('oAuthComplete', () => { + describe('when user completes oauth', () => { it('should run oAuthComplete', () => { context.getState.returns({ user: { @@ -185,7 +322,7 @@ describe('CompleteState', () => { state.enter(context); }); - it('should transition run redirect by default', () => { + it('should run redirect by default', () => { const expectedUrl = 'foo/bar'; const promise = Promise.resolve({redirectUri: expectedUrl}); @@ -261,6 +398,122 @@ describe('CompleteState', () => { it('should transition to permissions state if rejected with acceptRequired', () => testOAuth('reject', {acceptRequired: true}, PermissionsState) ); + + describe('when loginHint is set', () => { + const testSuccessLoginHint = (field) => { + const account = { + id: 9, + email: 'some@email.com', + username: 'thatUsername' + }; + + context.getState.returns({ + user: { + isActive: true, + isGuest: false + }, + accounts: { + available: [ + account + ], + active: { + id: 100 + } + }, + auth: { + oauth: { + clientId: 'ely.by', + loginHint: account[field], + prompt: [] + } + } + }); + + expectRun(mock, 'setAccountSwitcher', false); + expectRun(mock, 'authenticate', account) + .returns(Promise.resolve()); + expectState(mock, CompleteState); + + return expect(state.enter(context), 'to be fulfilled'); + }; + + it('should authenticate account if id matches', () => + testSuccessLoginHint('id') + ); + + it('should authenticate account if email matches', () => + testSuccessLoginHint('email') + ); + + it('should authenticate account if username matches', () => + testSuccessLoginHint('username') + ); + + it('should not authenticate if account is already authenticated', () => { + const account = { + id: 9, + email: 'some@email.com', + username: 'thatUsername' + }; + + context.getState.returns({ + user: { + isActive: true, + isGuest: false + }, + accounts: { + available: [ + account + ], + active: account + }, + auth: { + oauth: { + clientId: 'ely.by', + loginHint: account.id, + prompt: [] + } + } + }); + + expectRun(mock, 'setAccountSwitcher', false); + expectRun(mock, 'oAuthComplete', {}) + .returns({then: () => Promise.resolve()}); + + return expect(state.enter(context), 'to be fulfilled'); + }); + + it('should not authenticate if account was not found and continue auth', () => { + const account = { + id: 9, + email: 'some@email.com', + username: 'thatUsername' + }; + + context.getState.returns({ + user: { + isActive: true, + isGuest: false + }, + accounts: { + available: [{id: 1}], + active: {id: 1} + }, + auth: { + oauth: { + clientId: 'ely.by', + loginHint: account.id, + prompt: [] + } + } + }); + + expectRun(mock, 'oAuthComplete', {}) + .returns({then: () => Promise.resolve()}); + + return expect(state.enter(context), 'to be fulfilled'); + }); + }); }); describe('permissions accept', () => { diff --git a/tests/services/authFlow/LoginState.test.js b/tests/services/authFlow/LoginState.test.js index 4688559..0e91f2c 100644 --- a/tests/services/authFlow/LoginState.test.js +++ b/tests/services/authFlow/LoginState.test.js @@ -81,4 +81,12 @@ describe('LoginState', () => { return promise.catch(mock.verify.bind(mock)); }); }); + + describe('#goBack', () => { + it('should return to previous page', () => { + expectRun(mock, 'goBack', '/'); + + state.goBack(context); + }); + }); }); diff --git a/tests/services/authFlow/PasswordState.test.js b/tests/services/authFlow/PasswordState.test.js index d26c33a..ad1b906 100644 --- a/tests/services/authFlow/PasswordState.test.js +++ b/tests/services/authFlow/PasswordState.test.js @@ -1,3 +1,4 @@ +import expect from 'unexpected'; import sinon from 'sinon'; import PasswordState from 'services/authFlow/PasswordState'; @@ -69,27 +70,11 @@ describe('PasswordState', () => { rememberMe: expectedRememberMe, }) ).returns(Promise.resolve()); - - state.resolve(context, {password: expectedPassword, rememberMe: expectedRememberMe}); - }); - - it('should transition to complete state on successfull login', () => { - const promise = Promise.resolve(); - const expectedLogin = 'login'; - const expectedPassword = 'password'; - - context.getState.returns({ - auth: { - login: expectedLogin - } - }); - - mock.expects('run').returns(promise); expectState(mock, CompleteState); - state.resolve(context, {password: expectedPassword}); + const payload = {password: expectedPassword, rememberMe: expectedRememberMe}; - return promise; + return expect(state.resolve(context, payload), 'to be fulfilled'); }); }); diff --git a/tests/services/authFlow/RegisterState.test.js b/tests/services/authFlow/RegisterState.test.js index 94db655..1b2a895 100644 --- a/tests/services/authFlow/RegisterState.test.js +++ b/tests/services/authFlow/RegisterState.test.js @@ -34,16 +34,6 @@ describe('RegisterState', () => { state.enter(context); }); - - it('should transition to complete if not guest', () => { - context.getState.returns({ - user: {isGuest: false} - }); - - expectState(mock, CompleteState); - - state.enter(context); - }); }); describe('#resolve', () => { diff --git a/tests/services/authFlow/helpers.js b/tests/services/authFlow/helpers.js index 7c3ffe3..ec5e30c 100644 --- a/tests/services/authFlow/helpers.js +++ b/tests/services/authFlow/helpers.js @@ -2,6 +2,8 @@ * A helpers for testing states in isolation from AuthFlow */ +import sinon from 'sinon'; + export function bootstrap() { const context = { getState: sinon.stub(), @@ -28,9 +30,9 @@ export function expectState(mock, state) { export function expectNavigate(mock, route, options) { if (options) { return mock.expects('navigate').once().withExactArgs(route, sinon.match(options)); - } else { - return mock.expects('navigate').once().withExactArgs(route); } + + return mock.expects('navigate').once().withExactArgs(route); } export function expectRun(mock, ...args) {