diff --git a/package.json b/package.json index 0f4c716..f78bdea 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "up": "npm update", "test": "karma start ./karma.conf.js", "lint": "eslint ./src", - "i18n": "cd ./scripts && ./node_modules/.bin/babel-node i18n-collect.js", + "i18n": "babel-node ./scripts/i18n-collect.js", "build": "rm -rf dist/ && webpack --progress --colors -p" }, "dependencies": { @@ -33,6 +33,7 @@ "react-router": "^2.0.0", "react-router-redux": "^3.0.0", "redux": "^3.0.4", + "redux-localstorage": "^0.4.1", "redux-thunk": "^2.0.0", "webfontloader": "^1.6.26", "whatwg-fetch": "^1.0.0" @@ -50,6 +51,7 @@ "babel-preset-stage-0": "^6.3.13", "babel-runtime": "^6.0.0", "bundle-loader": "^0.5.4", + "circular-dependency-plugin": "^2.0.0", "css-loader": "^0.23.0", "enzyme": "^2.2.0", "eslint": "^3.1.1", diff --git a/scripts/i18n-collect.js b/scripts/i18n-collect.js index 6a4657c..ab8a0e6 100644 --- a/scripts/i18n-collect.js +++ b/scripts/i18n-collect.js @@ -5,8 +5,8 @@ import {sync as mkdirpSync} from 'mkdirp'; import chalk from 'chalk'; import prompt from 'prompt'; -const MESSAGES_PATTERN = '../dist/messages/**/*.json'; -const LANG_DIR = '../src/i18n'; +const MESSAGES_PATTERN = `${__dirname}/../dist/messages/**/*.json`; +const LANG_DIR = `${__dirname}/../src/i18n`; const DEFAULT_LOCALE = 'en'; const SUPPORTED_LANGS = [DEFAULT_LOCALE].concat('ru', 'be', 'uk'); diff --git a/src/components/accounts/AccountSwitcher.intl.json b/src/components/accounts/AccountSwitcher.intl.json new file mode 100644 index 0000000..e2ceb9c --- /dev/null +++ b/src/components/accounts/AccountSwitcher.intl.json @@ -0,0 +1,5 @@ +{ + "addAccount": "Add account", + "goToEly": "Go to Ely.by profile", + "logout": "Log out" +} diff --git a/src/components/accounts/AccountSwitcher.jsx b/src/components/accounts/AccountSwitcher.jsx new file mode 100644 index 0000000..3ce1663 --- /dev/null +++ b/src/components/accounts/AccountSwitcher.jsx @@ -0,0 +1,167 @@ +import React, { Component, PropTypes } from 'react'; + +import classNames from 'classnames'; +import { Link } from 'react-router'; +import { FormattedMessage as Message } from 'react-intl'; + +import loader from 'services/loader'; +import { skins, SKIN_DARK, COLOR_WHITE } from 'components/ui'; +import { Button } from 'components/ui/form'; + +import styles from './accountSwitcher.scss'; +import messages from './AccountSwitcher.intl.json'; + +export class AccountSwitcher extends Component { + static displayName = 'AccountSwitcher'; + + static propTypes = { + switchAccount: PropTypes.func.isRequired, + removeAccount: PropTypes.func.isRequired, + onAfterAction: PropTypes.func, // called after each action performed + onSwitch: PropTypes.func, // called after switching an account. The active account will be passed as arg + accounts: PropTypes.shape({ // TODO: accounts shape + active: PropTypes.shape({ + id: PropTypes.number + }), + available: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.number + })) + }), + skin: PropTypes.oneOf(skins), + highlightActiveAccount: PropTypes.bool, // whether active account should be expanded and shown on the top + allowLogout: PropTypes.bool, // whether to show logout icon near each account + allowAdd: PropTypes.bool // whether to show add account button + }; + + static defaultProps = { + skin: SKIN_DARK, + highlightActiveAccount: true, + allowLogout: true, + allowAdd: true, + onAfterAction() {}, + onSwitch() {} + }; + + render() { + const { accounts, skin, allowAdd, allowLogout, highlightActiveAccount } = this.props; + + let {available} = accounts; + + if (highlightActiveAccount) { + available = available.filter((account) => account.id !== accounts.active.id); + } + + return ( +
+ {highlightActiveAccount ? ( +
+
+
+
+ {accounts.active.username} +
+
+ {accounts.active.email} +
+
+
+ + + +
+
+ + + +
+
+
+
+ ) : null} + {available.map((account, id) => ( +
+
+ + {allowLogout ? ( +
+ ) : ( +
+ )} + +
+
+ {account.username} +
+
+ {account.email} +
+
+
+ ))} + {allowAdd ? ( + + -
- ); - } - - onLogout = (event) => { - event.preventDefault(); - - this.props.onLogout(); - }; -} diff --git a/src/components/userbar/LoggedInPanel.jsx b/src/components/userbar/LoggedInPanel.jsx new file mode 100644 index 0000000..0ca1b18 --- /dev/null +++ b/src/components/userbar/LoggedInPanel.jsx @@ -0,0 +1,98 @@ +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; + +import classNames from 'classnames'; + +import buttons from 'components/ui/buttons.scss'; +import { AccountSwitcher } from 'components/accounts'; + +import styles from './loggedInPanel.scss'; + +import { userShape } from 'components/user/User'; + +export default class LoggedInPanel extends Component { + static displayName = 'LoggedInPanel'; + static propTypes = { + user: userShape + }; + + state = { + isAccountSwitcherActive: false + }; + + componentDidMount() { + document.addEventListener('click', this.onBodyClick); + } + + componentWillUnmount() { + document.removeEventListener('click', this.onBodyClick); + } + + render() { + const { user } = this.props; + const { isAccountSwitcherActive } = this.state; + + return ( +
+
+ + +
+ +
+
+
+ ); + } + + toggleAccountSwitcher = () => this.setState({ + isAccountSwitcherActive: !this.state.isAccountSwitcherActive + }); + + onExpandAccountSwitcher = (event) => { + event.preventDefault(); + + this.toggleAccountSwitcher(); + }; + + onBodyClick = createOnOutsideComponentClickHandler( + () => ReactDOM.findDOMNode(this), + () => this.state.isAccountSwitcherActive, + () => this.toggleAccountSwitcher() + ); +} + +/** + * Creates an event handling function to handle clicks outside the component + * + * The handler will check if current click was outside container el and if so + * and component isActive, it will call the callback + * + * @param {function} getEl - the function, that returns reference to container el + * @param {function} isActive - whether the component is active and callback may be called + * @param {function} callback - the callback to call, when there was a click outside el + * @return {function} + */ +function createOnOutsideComponentClickHandler(getEl, isActive, callback) { + // TODO: we have the same logic in LangMenu + // Probably we should decouple this into some helper function + // TODO: the name of function may be better... + return (event) => { + if (isActive()) { + const el = getEl(); + + if (!el.contains(event.target) && el !== event.taget) { + event.preventDefault(); + + // add a small delay for the case someone have alredy called toggle + setTimeout(() => isActive() && callback(), 0); + } + } + }; +} diff --git a/src/components/userbar/loggedInPanel.scss b/src/components/userbar/loggedInPanel.scss index 631a0ad..3264b96 100644 --- a/src/components/userbar/loggedInPanel.scss +++ b/src/components/userbar/loggedInPanel.scss @@ -1,5 +1,33 @@ +@import '~components/ui/colors.scss'; + .loggedInPanel { - justify-content: flex-end; +} + +.activeAccount { + position: relative; + display: inline-block; + + $border: 1px solid rgba(#fff, .15); + border-left: $border; + border-right: $border; +} + +.activeAccountButton { + composes: green from 'components/ui/buttons.scss'; +} + +.activeAccountExpanded { + .activeAccountButton { + background-color: darker($green); + } + + .accountSwitcherContainer { + display: block; + } + + .expandIcon { + transform: rotate(-180deg); + } } .userIcon { @@ -11,12 +39,24 @@ padding-right: 5px; } +.expandIcon { + composes: caret from 'components/ui/icons.scss'; + + margin-left: 4px; + font-size: 6px; + color: #CCC; + transition: .2s; +} + .userName { } -.logoutIcon { - composes: exit from 'components/ui/icons.scss'; +.accountSwitcherContainer { + position: absolute; + top: 100%; + right: -2px; + cursor: auto; - color: #cdcdcd; + display: none; } diff --git a/src/i18n/be.json b/src/i18n/be.json index 461a64e..06c61c7 100644 --- a/src/i18n/be.json +++ b/src/i18n/be.json @@ -1,4 +1,7 @@ { + "components.accounts.addAccount": "Дадаць акаўнт", + "components.accounts.goToEly": "Перайсці ў профіль Ely.by", + "components.accounts.logout": "Выйсці", "components.auth.acceptRules.accept": "Прыняць", "components.auth.acceptRules.declineAndLogout": "Адмовіцца і выйсці", "components.auth.acceptRules.description1": "Мы аднавілі {link}.", @@ -15,6 +18,10 @@ "components.auth.appInfo.documentation": "дакументацыю", "components.auth.appInfo.goToAuth": "Да аўтарызацыі", "components.auth.appInfo.useItYourself": "Наведайце нашу {link}, каб даведацца, як выкарыстоўваць гэты сэрвіс ў сваіх праектах.", + "components.auth.chooseAccount.addAccount": "Увайсці ў другі акаўнт", + "components.auth.chooseAccount.chooseAccountTitle": "Выбар акаўнта", + "components.auth.chooseAccount.description": "Вы выканалі ўваход у некалькі акаўнтаў. Пазначце, які вы жадаеце выкарыстаць для аўтарызацыі {appName}", + "components.auth.chooseAccount.logoutAll": "Выйсці з усіх акаўтаў", "components.auth.finish.authForAppFailed": "Аўтарызацыя для {appName} не атрымалася", "components.auth.finish.authForAppSuccessful": "Аўтарызацыя для {appName} паспяхова выканана", "components.auth.finish.copy": "Скапіяваць", @@ -126,7 +133,6 @@ "components.profile.projectRules": "правілах праекта", "components.profile.twoFactorAuth": "Двухфактарная аўтэнтыфікацыя", "components.userbar.login": "Уваход", - "components.userbar.logout": "Выхад", "components.userbar.register": "Рэгістрацыя", "pages.root.siteName": "Ёly.by", "pages.rules.elyAccountsAsService": "{name} як сэрвіс", diff --git a/src/i18n/en.json b/src/i18n/en.json index c38c0dc..ba29676 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1,4 +1,7 @@ { + "components.accounts.addAccount": "Add account", + "components.accounts.goToEly": "Go to Ely.by profile", + "components.accounts.logout": "Log out", "components.auth.acceptRules.accept": "Accept", "components.auth.acceptRules.declineAndLogout": "Decline and logout", "components.auth.acceptRules.description1": "We have updated our {link}.", @@ -15,6 +18,10 @@ "components.auth.appInfo.documentation": "documentation", "components.auth.appInfo.goToAuth": "Go to auth", "components.auth.appInfo.useItYourself": "Visit our {link}, to learn how to use this service in you projects.", + "components.auth.chooseAccount.addAccount": "Log into another account", + "components.auth.chooseAccount.chooseAccountTitle": "Choose an account", + "components.auth.chooseAccount.description": "You have logged in into multiple accounts. Please choose the one, you want to use to authorize {appName}", + "components.auth.chooseAccount.logoutAll": "Log out from all accounts", "components.auth.finish.authForAppFailed": "Authorization for {appName} was failed", "components.auth.finish.authForAppSuccessful": "Authorization for {appName} was successfully completed", "components.auth.finish.copy": "Copy", @@ -126,7 +133,6 @@ "components.profile.projectRules": "project rules", "components.profile.twoFactorAuth": "Two factor auth", "components.userbar.login": "Sign in", - "components.userbar.logout": "Logout", "components.userbar.register": "Join", "pages.root.siteName": "Ely.by", "pages.rules.elyAccountsAsService": "{name} as service", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 1007783..2f7a722 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -1,4 +1,7 @@ { + "components.accounts.addAccount": "Добавить акккаунт", + "components.accounts.goToEly": "Перейти в профиль Ely.by", + "components.accounts.logout": "Выйти", "components.auth.acceptRules.accept": "Принять", "components.auth.acceptRules.declineAndLogout": "Отказаться и выйти", "components.auth.acceptRules.description1": "Мы обновили {link}.", @@ -15,6 +18,10 @@ "components.auth.appInfo.documentation": "документацию", "components.auth.appInfo.goToAuth": "К авторизации", "components.auth.appInfo.useItYourself": "Посетите нашу {link}, чтобы узнать, как использовать этот сервис в своих проектах.", + "components.auth.chooseAccount.addAccount": "Войти в другой аккаунт", + "components.auth.chooseAccount.chooseAccountTitle": "Выбор аккаунта", + "components.auth.chooseAccount.description": "Вы выполнили вход в несколько аккаунтов. Укажите, какой вы хотите использовать для авторизации {appName}", + "components.auth.chooseAccount.logoutAll": "Выйти из всех аккаунтов", "components.auth.finish.authForAppFailed": "Авторизация для {appName} не удалась", "components.auth.finish.authForAppSuccessful": "Авторизация для {appName} успешно выполнена", "components.auth.finish.copy": "Скопировать", @@ -126,7 +133,6 @@ "components.profile.projectRules": "правилами проекта", "components.profile.twoFactorAuth": "Двухфакторная аутентификация", "components.userbar.login": "Вход", - "components.userbar.logout": "Выход", "components.userbar.register": "Регистрация", "pages.root.siteName": "Ely.by", "pages.rules.elyAccountsAsService": "{name} как сервис", diff --git a/src/i18n/uk.json b/src/i18n/uk.json index bcfe58e..7225fc1 100644 --- a/src/i18n/uk.json +++ b/src/i18n/uk.json @@ -1,4 +1,7 @@ { + "components.accounts.addAccount": "Додати акаунт", + "components.accounts.goToEly": "Профіль на Ely.by", + "components.accounts.logout": "Вихід", "components.auth.acceptRules.accept": "Прийняти", "components.auth.acceptRules.declineAndLogout": "Відмовитись і вийти", "components.auth.acceptRules.description1": "Ми оновили наші {link}.", @@ -15,6 +18,10 @@ "components.auth.appInfo.documentation": "документацію", "components.auth.appInfo.goToAuth": "До авторизації", "components.auth.appInfo.useItYourself": "Відвідайте нашу {link}, щоб дізнатися, як використовувати цей сервіс в своїх проектах.", + "components.auth.chooseAccount.addAccount": "Увійти в інший акаунт", + "components.auth.chooseAccount.chooseAccountTitle": "Оберіть акаунт", + "components.auth.chooseAccount.description": "Ви увійшли у декілька акаунтів. Будь ласка, оберіть акаунт, який ви бажаєте використовувати для авторизації {appName}", + "components.auth.chooseAccount.logoutAll": "Вийти з усіх аккаунтів", "components.auth.finish.authForAppFailed": "Авторизація для {appName} не вдалася", "components.auth.finish.authForAppSuccessful": "Авторизація для {appName} успішно виконана", "components.auth.finish.copy": "Скопіювати", @@ -126,7 +133,6 @@ "components.profile.projectRules": "правилами проекта", "components.profile.twoFactorAuth": "Двофакторна аутентифікація", "components.userbar.login": "Вхід", - "components.userbar.logout": "Вихід", "components.userbar.register": "Реєстрація", "pages.root.siteName": "Ely.by", "pages.rules.elyAccountsAsService": "{name} як сервіс", @@ -140,6 +146,7 @@ "pages.rules.emailAndNickname3": "На призначений для користувача нікнейм, який використовується у грі, не накладаються будь-які моральні обмеження.", "pages.rules.emailAndNickname4": "Ніки, що належать відомим особистостям, за вимогою, можуть бути звільнені у їх користь після встановленню цієї самої особистості.", "pages.rules.emailAndNickname5": "Власник преміум-аккаунта Minecraft має право вимагати відновлення контролю над своїм ніком. У цьому випадку вам необхідно буде протягом 3-х днів змінити нік, або це буде зроблено автоматично.", + "pages.rules.emailAndNickname6": "Якщо на вашому акаунті не було активності протягом останніх 3 місяців, ваш нік може будти зайнятий іншим користовичем.", "pages.rules.emailAndNickname7": "Ми не несемо відповідальності за втрачений прогрес на ігрових серверах у результаті зміни ника, включаючи випадки зміни ника на вимогу з нашого боку.", "pages.rules.mainProvision1": "Сервіс {name} призначений для організації безпечного доступу до призначених для користувача аккаунтів проекту Ely.by, його партнерів і будь-яких сторонніх проектів, які бажають використовувати один з наших сервісів.", "pages.rules.mainProvision2": "Ми (тут і надалі) — команда розробників проекту Ely.by, що займаються створенням якісних сервісів для спільноти Minecraft.", diff --git a/src/icons/webfont/minecraft-character.svg b/src/icons/webfont/minecraft-character.svg new file mode 100644 index 0000000..2e0711a --- /dev/null +++ b/src/icons/webfont/minecraft-character.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/icons/webfont/plus.svg b/src/icons/webfont/plus.svg new file mode 100644 index 0000000..878154f --- /dev/null +++ b/src/icons/webfont/plus.svg @@ -0,0 +1,7 @@ + + + + diff --git a/src/index.js b/src/index.js index a5d083b..8c5d79b 100644 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,7 @@ import { IntlProvider } from 'components/i18n'; import routesFactory from 'routes'; import storeFactory from 'storeFactory'; import bsodFactory from 'components/ui/bsod/factory'; +import loader from 'services/loader'; const store = storeFactory(); @@ -52,7 +53,7 @@ Promise.all([ function stopLoading() { - document.getElementById('loader').classList.remove('is-active'); + loader.hide(); } import scrollTo from 'components/ui/scrollTo'; @@ -89,7 +90,10 @@ function restoreScroll() { /* global process: false */ if (process.env.NODE_ENV !== 'production') { // some shortcuts for testing on localhost - window.testOAuth = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email'; + window.testOAuth = (loginHint = '') => location.href = `/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&login_hint=${loginHint}`; + window.testOAuthPromptAccount = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&prompt=select_account'; + window.testOAuthPromptPermissions = (loginHint = '') => location.href = `/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&prompt=consent&login_hint=${loginHint}`; + window.testOAuthPromptAll = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&prompt=select_account,consent'; window.testOAuthStatic = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=static_page_with_code&response_type=code&scope=account_info%2Caccount_email'; window.testOAuthStaticCode = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=static_page&response_type=code&scope=account_info%2Caccount_email'; diff --git a/src/index.scss b/src/index.scss index 9f91f8d..bd56c3a 100644 --- a/src/index.scss +++ b/src/index.scss @@ -11,7 +11,7 @@ body, body { font-family: $font-family-base; - background: $light; + background: $white; color: #444; font-size: 16px; } diff --git a/src/pages/root/RootPage.jsx b/src/pages/root/RootPage.jsx index 0f6beec..1d2f2a4 100644 --- a/src/pages/root/RootPage.jsx +++ b/src/pages/root/RootPage.jsx @@ -31,12 +31,11 @@ function RootPage(props) { })}>
- +
@@ -58,16 +57,16 @@ RootPage.propTypes = { pathname: PropTypes.string }).isRequired, children: PropTypes.element, - logout: PropTypes.func.isRequired, + resetOAuth: PropTypes.func.isRequired, isPopupActive: PropTypes.bool.isRequired }; import { connect } from 'react-redux'; -import { logout } from 'components/user/actions'; +import { resetOAuth } from 'components/auth/actions'; export default connect((state) => ({ user: state.user, isPopupActive: state.popup.popups.length > 0 }), { - logout + resetOAuth })(RootPage); diff --git a/src/pages/rules/rules.scss b/src/pages/rules/rules.scss index cf2e72f..4fc9f50 100644 --- a/src/pages/rules/rules.scss +++ b/src/pages/rules/rules.scss @@ -74,7 +74,7 @@ left: -40px; width: calc(100% + 60px); height: calc(100% + 20px); - background: $light; + background: $white; border-left: $border; border-right: $border; box-sizing: border-box; diff --git a/src/reducers.js b/src/reducers.js index 90bab89..7161456 100644 --- a/src/reducers.js +++ b/src/reducers.js @@ -4,6 +4,7 @@ import { routeReducer } from 'react-router-redux'; import auth from 'components/auth/reducer'; import user from 'components/user/reducer'; +import accounts from 'components/accounts/reducer'; import i18n from 'components/i18n/reducer'; import popup from 'components/ui/popup/reducer'; import bsod from 'components/ui/bsod/reducer'; @@ -12,6 +13,7 @@ export default combineReducers({ bsod, auth, user, + accounts, i18n, popup, routing: routeReducer diff --git a/src/routes.js b/src/routes.js index b61ed33..b71b57f 100644 --- a/src/routes.js +++ b/src/routes.js @@ -17,6 +17,7 @@ import OAuthInit from 'components/auth/OAuthInit'; import Register from 'components/auth/register/Register'; import Login from 'components/auth/login/Login'; import Permissions from 'components/auth/permissions/Permissions'; +import ChooseAccount from 'components/auth/chooseAccount/ChooseAccount'; import Activation from 'components/auth/activation/Activation'; import ResendActivation from 'components/auth/resendActivation/ResendActivation'; import Password from 'components/auth/password/Password'; @@ -62,6 +63,7 @@ export default function routesFactory(store) { + diff --git a/src/services/api/accounts.js b/src/services/api/accounts.js index 34dac16..05ce95c 100644 --- a/src/services/api/accounts.js +++ b/src/services/api/accounts.js @@ -1,8 +1,17 @@ import request from 'services/request'; export default { - current() { - return request.get('/api/accounts/current'); + /** + * @param {object} options + * @param {object} [options.token] - an optional token to overwrite headers + * in middleware and disable token auto-refresh + * + * @return {Promise} + */ + current(options = {}) { + return request.get('/api/accounts/current', {}, { + token: options.token + }); }, changePassword({ diff --git a/src/services/api/authentication.js b/src/services/api/authentication.js index 52e5fa7..b495f5b 100644 --- a/src/services/api/authentication.js +++ b/src/services/api/authentication.js @@ -1,6 +1,7 @@ import request from 'services/request'; +import accounts from 'services/api/accounts'; -export default { +const authentication = { login({ login = '', password = '', @@ -12,8 +13,17 @@ export default { ); }, - logout() { - return request.post('/api/authentication/logout'); + /** + * @param {object} options + * @param {object} [options.token] - an optional token to overwrite headers + * in middleware and disable token auto-refresh + * + * @return {Promise} + */ + logout(options = {}) { + return request.post('/api/authentication/logout', {}, { + token: options.token + }); }, forgotPassword({ @@ -36,6 +46,40 @@ export default { ); }, + /** + * Resolves if token is valid + * + * @param {object} options + * @param {string} options.token + * @param {string} options.refreshToken + * + * @return {Promise} - resolves with options.token or with a new token + * if it was refreshed + */ + validateToken({token, refreshToken}) { + return new Promise((resolve) => { + if (typeof token !== 'string') { + throw new Error('token must be a string'); + } + + if (typeof refreshToken !== 'string') { + throw new Error('refreshToken must be a string'); + } + + resolve(); + }) + .then(() => accounts.current({token})) + .then(() => ({token, refreshToken})) + .catch((resp) => { + if (resp.message === 'Token expired') { + return authentication.requestToken(refreshToken) + .then(({token}) => ({token, refreshToken})); + } + + return Promise.reject(resp); + }); + }, + /** * Request new access token using a refreshToken * @@ -52,3 +96,5 @@ export default { })); } }; + +export default authentication; diff --git a/src/services/api/oauth.js b/src/services/api/oauth.js index bb2194e..f374f00 100644 --- a/src/services/api/oauth.js +++ b/src/services/api/oauth.js @@ -57,6 +57,8 @@ function getOAuthRequest(oauthData) { response_type: oauthData.responseType, description: oauthData.description, scope: oauthData.scope, + prompt: oauthData.prompt, + login_hint: oauthData.loginHint, state: oauthData.state }; } diff --git a/src/services/authFlow/AuthFlow.js b/src/services/authFlow/AuthFlow.js index f23779a..f205df4 100644 --- a/src/services/authFlow/AuthFlow.js +++ b/src/services/authFlow/AuthFlow.js @@ -158,6 +158,7 @@ export default class AuthFlow { case '/accept-rules': case '/oauth/permissions': case '/oauth/finish': + case '/oauth/choose-account': this.setState(new LoginState()); break; @@ -191,8 +192,8 @@ export default class AuthFlow { * @return {bool} - whether oauth state is being restored */ restoreOAuthState() { - if (this.getRequest().path.indexOf('/register') === 0) { - // allow register + if (/^\/(register|oauth2)/.test(this.getRequest().path)) { + // allow register or the new oauth requests return; } diff --git a/src/services/authFlow/ChooseAccountState.js b/src/services/authFlow/ChooseAccountState.js new file mode 100644 index 0000000..ffa57b6 --- /dev/null +++ b/src/services/authFlow/ChooseAccountState.js @@ -0,0 +1,25 @@ +import AbstractState from './AbstractState'; +import LoginState from './LoginState'; +import CompleteState from './CompleteState'; + +export default class ChooseAccountState extends AbstractState { + enter(context) { + context.navigate('/oauth/choose-account'); + } + + 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 { + context.navigate('/login'); + context.setState(new LoginState()); + } + } + + reject(context) { + context.run('logout'); + } +} diff --git a/src/services/authFlow/CompleteState.js b/src/services/authFlow/CompleteState.js index 248090f..3e0aae0 100644 --- a/src/services/authFlow/CompleteState.js +++ b/src/services/authFlow/CompleteState.js @@ -1,10 +1,14 @@ import AbstractState from './AbstractState'; import LoginState from './LoginState'; import PermissionsState from './PermissionsState'; +import ChooseAccountState from './ChooseAccountState'; import ActivationState from './ActivationState'; import AcceptRulesState from './AcceptRulesState'; import FinishState from './FinishState'; +const PROMPT_ACCOUNT_CHOOSE = 'select_account'; +const PROMPT_PERMISSIONS = 'consent'; + export default class CompleteState extends AbstractState { constructor(options = {}) { super(options); @@ -13,7 +17,7 @@ export default class CompleteState extends AbstractState { } enter(context) { - const {auth = {}, user} = context.getState(); + const {auth = {}, user, accounts} = context.getState(); if (user.isGuest) { context.setState(new LoginState()); @@ -22,13 +26,41 @@ export default class CompleteState extends AbstractState { } else if (user.shouldAcceptRules) { context.setState(new AcceptRulesState()); } else if (auth.oauth && auth.oauth.clientId) { - if (auth.oauth.code) { + 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) { + } else if (auth.oauth.acceptRequired || auth.oauth.prompt.includes(PROMPT_PERMISSIONS)) { context.setState(new PermissionsState()); return; } diff --git a/src/services/authFlow/LoginState.js b/src/services/authFlow/LoginState.js index 5ee0fb0..cb63349 100644 --- a/src/services/authFlow/LoginState.js +++ b/src/services/authFlow/LoginState.js @@ -3,12 +3,18 @@ import PasswordState from './PasswordState'; export default class LoginState extends AbstractState { 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()); - } 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'); + } else { + context.setState(new PasswordState()); } } diff --git a/src/services/authFlow/OAuthState.js b/src/services/authFlow/OAuthState.js index 90ef6cc..e556cbc 100644 --- a/src/services/authFlow/OAuthState.js +++ b/src/services/authFlow/OAuthState.js @@ -11,6 +11,8 @@ export default class OAuthState extends AbstractState { responseType: query.response_type, description: query.description, scope: query.scope, + prompt: query.prompt, + loginHint: query.login_hint, state: query.state }).then(() => context.setState(new CompleteState())); } diff --git a/src/services/authFlow/PasswordState.js b/src/services/authFlow/PasswordState.js index 13e6c22..039f54b 100644 --- a/src/services/authFlow/PasswordState.js +++ b/src/services/authFlow/PasswordState.js @@ -5,9 +5,9 @@ import LoginState from './LoginState'; export default class PasswordState extends AbstractState { enter(context) { - const {user} = context.getState(); + const {auth} = context.getState(); - if (user.isGuest) { + if (auth.login) { context.navigate('/password'); } else { context.setState(new CompleteState()); @@ -15,12 +15,12 @@ export default class PasswordState extends AbstractState { } resolve(context, {password, rememberMe}) { - const {user} = context.getState(); + const {auth: {login}} = context.getState(); context.run('login', { password, rememberMe, - login: user.email || user.username + login }) .then(() => context.setState(new CompleteState())); } @@ -30,7 +30,7 @@ export default class PasswordState extends AbstractState { } goBack(context) { - context.run('logout'); + context.run('setLogin', null); context.setState(new LoginState()); } } diff --git a/src/services/authFlow/index.js b/src/services/authFlow/index.js index c75fd4c..8a365e3 100644 --- a/src/services/authFlow/index.js +++ b/src/services/authFlow/index.js @@ -1,11 +1,9 @@ import AuthFlow from './AuthFlow'; import * as actions from 'components/auth/actions'; -import {updateUser} from 'components/user/actions'; const availableActions = { - ...actions, - updateUser + ...actions }; export default new AuthFlow(availableActions); diff --git a/src/services/loader.js b/src/services/loader.js new file mode 100644 index 0000000..c1d8619 --- /dev/null +++ b/src/services/loader.js @@ -0,0 +1,9 @@ +export default { + show() { + document.getElementById('loader').classList.add('is-active'); + }, + + hide() { + document.getElementById('loader').classList.remove('is-active'); + } +}; diff --git a/src/services/request/request.js b/src/services/request/request.js index a94a433..7ea6640 100644 --- a/src/services/request/request.js +++ b/src/services/request/request.js @@ -5,33 +5,36 @@ const middlewareLayer = new PromiseMiddlewareLayer(); export default { /** * @param {string} url - * @param {object} data + * @param {object} data - request data + * @param {object} options - additional options for fetch or middlewares * * @return {Promise} */ - post(url, data) { + post(url, data, options = {}) { return doFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, - body: buildQuery(data) + body: buildQuery(data), + ...options }); }, /** * @param {string} url - * @param {object} data + * @param {object} data - request data + * @param {object} options - additional options for fetch or middlewares * * @return {Promise} */ - get(url, data) { - if (typeof data === 'object') { + get(url, data, options = {}) { + if (typeof data === 'object' && Object.keys(data).length) { const separator = url.indexOf('?') === -1 ? '?' : '&'; url += separator + buildQuery(data); } - return doFetch(url); + return doFetch(url, options); }, /** @@ -82,8 +85,8 @@ function doFetch(url, options = {}) { .then(checkStatus) .then(toJSON, rejectWithJSON) .then(handleResponseSuccess) - .then((resp) => middlewareLayer.run('then', resp)) - .catch((resp) => middlewareLayer.run('catch', resp, () => doFetch(url, options))) + .then((resp) => middlewareLayer.run('then', resp, {url, options})) + .catch((resp) => middlewareLayer.run('catch', resp, {url, options}, () => doFetch(url, options))) ; } diff --git a/src/storeFactory.js b/src/storeFactory.js index 0febcd6..b393efd 100644 --- a/src/storeFactory.js +++ b/src/storeFactory.js @@ -4,6 +4,7 @@ import { createStore, applyMiddleware, compose } from 'redux'; // а также дает возможность проверить какие-либо условия перед запуском экшена // или даже вообще его не запускать в зависимости от условий import thunk from 'redux-thunk'; +import persistState from 'redux-localstorage'; import { syncHistory } from 'react-router-redux'; import { browserHistory } from 'react-router'; @@ -15,14 +16,18 @@ export default function storeFactory() { reduxRouterMiddleware, thunk ); + const persistStateEnhancer = persistState([ + 'accounts', + 'user' + ], {key: 'redux-storage'}); /* global process: false */ let enhancer; if (process.env.NODE_ENV === 'production') { - enhancer = compose(middlewares); + enhancer = compose(middlewares, persistStateEnhancer); } else { const DevTools = require('containers/DevTools').default; - enhancer = compose(middlewares, DevTools.instrument()); + enhancer = compose(middlewares, persistStateEnhancer, DevTools.instrument()); } const store = createStore(reducers, {}, enhancer); diff --git a/tests/components/accounts/actions.test.js b/tests/components/accounts/actions.test.js new file mode 100644 index 0000000..297dbdd --- /dev/null +++ b/tests/components/accounts/actions.test.js @@ -0,0 +1,245 @@ +import expect from 'unexpected'; + +import accounts from 'services/api/accounts'; +import authentication from 'services/api/authentication'; +import { + authenticate, + revoke, + add, ADD, + activate, ACTIVATE, + remove, + reset, + logoutAll +} from 'components/accounts/actions'; +import { SET_LOCALE } from 'components/i18n/actions'; + +import { updateUser } from 'components/user/actions'; + +const account = { + id: 1, + username: 'username', + email: 'email@test.com', + token: 'foo', + refreshToken: 'bar' +}; + +const user = { + id: 1, + username: 'username', + email: 'email@test.com', + lang: 'be' +}; + +describe('components/accounts/actions', () => { + let dispatch; + let getState; + + beforeEach(() => { + dispatch = sinon.spy((arg) => + typeof arg === 'function' ? arg(dispatch, getState) : arg + ).named('store.dispatch'); + getState = sinon.stub().named('store.getState'); + + getState.returns({ + accounts: [], + user: {} + }); + + sinon.stub(authentication, 'validateToken').named('authentication.validateToken'); + authentication.validateToken.returns(Promise.resolve({ + token: account.token, + refreshToken: account.refreshToken + })); + + sinon.stub(accounts, 'current').named('accounts.current'); + accounts.current.returns(Promise.resolve(user)); + }); + + afterEach(() => { + authentication.validateToken.restore(); + accounts.current.restore(); + }); + + describe('#authenticate()', () => { + it('should request user state using token', () => + authenticate(account)(dispatch).then(() => + expect(accounts.current, 'to have a call satisfying', [ + {token: account.token} + ]) + ) + ); + + it(`dispatches ${ADD} action`, () => + authenticate(account)(dispatch).then(() => + expect(dispatch, 'to have a call satisfying', [ + add(account) + ]) + ) + ); + + it(`dispatches ${ACTIVATE} action`, () => + authenticate(account)(dispatch).then(() => + expect(dispatch, 'to have a call satisfying', [ + activate(account) + ]) + ) + ); + + it(`dispatches ${SET_LOCALE} action`, () => + authenticate(account)(dispatch).then(() => + expect(dispatch, 'to have a call satisfying', [ + {type: SET_LOCALE, payload: {locale: 'be'}} + ]) + ) + ); + + it('should update user state', () => + authenticate(account)(dispatch).then(() => + expect(dispatch, 'to have a call satisfying', [ + updateUser({...user, isGuest: false}) + ]) + ) + ); + + it('resolves with account', () => + authenticate(account)(dispatch).then((resp) => + expect(resp, 'to equal', account) + ) + ); + + it('rejects when bad auth data', () => { + accounts.current.returns(Promise.reject({})); + + return expect(authenticate(account)(dispatch), 'to be rejected').then(() => + expect(dispatch, 'was not called') + ); + }); + }); + + describe('#revoke()', () => { + beforeEach(() => { + sinon.stub(authentication, 'logout').named('authentication.logout'); + }); + + afterEach(() => { + authentication.logout.restore(); + }); + + describe('when one account available', () => { + beforeEach(() => { + getState.returns({ + accounts: { + active: account, + available: [account] + }, + user + }); + }); + + it('should dispatch reset action', () => + revoke(account)(dispatch, getState).then(() => + expect(dispatch, 'to have a call satisfying', [ + reset() + ]) + ) + ); + + it('should call logout api method in background', () => + revoke(account)(dispatch, getState).then(() => + expect(authentication.logout, 'to have a call satisfying', [ + account + ]) + ) + ); + + it('should update user state', () => + revoke(account)(dispatch, getState).then(() => + expect(dispatch, 'to have a call satisfying', [ + {payload: {isGuest: true}} + // updateUser({isGuest: true}) + ]) + // expect(dispatch, 'to have calls satisfying', [ + // [remove(account)], + // [expect.it('to be a function')] + // // [logout()] // TODO: this is not a plain action. How should we simplify its testing? + // ]) + ) + ); + }); + + describe('when multiple accounts available', () => { + const account2 = {...account, id: 2}; + + beforeEach(() => { + getState.returns({ + accounts: { + active: account2, + available: [account, account2] + }, + user + }); + }); + + it('should switch to the next account', () => + revoke(account2)(dispatch, getState).then(() => + expect(dispatch, 'to have a call satisfying', [ + activate(account) + ]) + ) + ); + + it('should remove current account', () => + revoke(account2)(dispatch, getState).then(() => + expect(dispatch, 'to have a call satisfying', [ + remove(account2) + ]) + ) + ); + + it('should call logout api method in background', () => + revoke(account2)(dispatch, getState).then(() => + expect(authentication.logout, 'to have a call satisfying', [ + account2 + ]) + ) + ); + }); + }); + + describe('#logoutAll()', () => { + const account2 = {...account, id: 2}; + + beforeEach(() => { + getState.returns({ + accounts: { + active: account2, + available: [account, account2] + }, + user + }); + + sinon.stub(authentication, 'logout').named('authentication.logout'); + }); + + afterEach(() => { + authentication.logout.restore(); + }); + + it('should call logout api method for each account', () => { + logoutAll()(dispatch, getState); + + expect(authentication.logout, 'to have calls satisfying', [ + [account], + [account2] + ]); + }); + + it('should dispatch reset', () => { + logoutAll()(dispatch, getState); + + expect(dispatch, 'to have a call satisfying', [ + reset() + ]); + }); + }); +}); diff --git a/tests/components/accounts/reducer.test.js b/tests/components/accounts/reducer.test.js new file mode 100644 index 0000000..140766e --- /dev/null +++ b/tests/components/accounts/reducer.test.js @@ -0,0 +1,120 @@ +import expect from 'unexpected'; + +import accounts from 'components/accounts/reducer'; +import { + updateToken, add, remove, activate, reset, + ADD, REMOVE, ACTIVATE, UPDATE_TOKEN, RESET +} from 'components/accounts/actions'; + +const account = { + id: 1, + username: 'username', + email: 'email@test.com', + token: 'foo', + refreshToken: 'foo' +}; + +describe('Accounts reducer', () => { + let initial; + + beforeEach(() => { + initial = accounts(undefined, {}); + }); + + it('should be empty', () => expect(accounts(undefined, {}), 'to equal', { + active: null, + available: [] + })); + + it('should return last state if unsupported action', () => + expect(accounts({state: 'foo'}, {}), 'to equal', {state: 'foo'}) + ); + + describe(ACTIVATE, () => { + it('sets active account', () => { + expect(accounts(initial, activate(account)), 'to satisfy', { + active: account + }); + }); + }); + + describe(ADD, () => { + it('adds an account', () => + expect(accounts(initial, add(account)), 'to satisfy', { + available: [account] + }) + ); + + it('should replace if account was added for the second time', () => { + const outdatedAccount = { + ...account, + someShit: true + }; + + const updatedAccount = { + ...account, + token: 'newToken' + }; + + expect( + accounts({...initial, available: [outdatedAccount]}, add(updatedAccount)), + 'to satisfy', { + available: [updatedAccount] + }); + }); + + it('should sort accounts by username', () => { + const newAccount = { + ...account, + id: 2, + username: 'abc' + }; + + expect(accounts({...initial, available: [account]}, add(newAccount)), + 'to satisfy', { + available: [newAccount, account] + }); + }); + + it('throws, when account is invalid', () => { + expect(() => accounts(initial, add()), + 'to throw', 'Invalid or empty payload passed for accounts.add'); + }); + }); + + describe(REMOVE, () => { + it('should remove an account', () => + expect(accounts({...initial, available: [account]}, remove(account)), + 'to equal', initial) + ); + + it('throws, when account is invalid', () => { + expect(() => accounts(initial, remove()), + 'to throw', 'Invalid or empty payload passed for accounts.remove'); + }); + }); + + describe(RESET, () => { + it('should reset accounts state', () => + expect(accounts({...initial, available: [account]}, reset()), + 'to equal', initial) + ); + }); + + describe(UPDATE_TOKEN, () => { + it('should update token', () => { + const newToken = 'newToken'; + + expect(accounts( + {active: account, available: [account]}, + updateToken(newToken) + ), 'to satisfy', { + active: { + ...account, + token: newToken + }, + available: [account] + }); + }); + }); +}); diff --git a/tests/components/auth/actions.test.js b/tests/components/auth/actions.test.js index 4c7ff9f..4e005b4 100644 --- a/tests/components/auth/actions.test.js +++ b/tests/components/auth/actions.test.js @@ -10,7 +10,9 @@ import { setOAuthRequest, setScopes, setOAuthCode, - requirePermissionsAccept + requirePermissionsAccept, + login, + setLogin } from 'components/auth/actions'; const oauthData = { @@ -22,8 +24,8 @@ const oauthData = { }; describe('components/auth/actions', () => { - const dispatch = sinon.stub().named('dispatch'); - const getState = sinon.stub().named('getState'); + const dispatch = sinon.stub().named('store.dispatch'); + const getState = sinon.stub().named('store.getState'); function callThunk(fn, ...args) { const thunk = fn(...args); @@ -67,21 +69,25 @@ describe('components/auth/actions', () => { request.get.returns(Promise.resolve(resp)); }); - it('should send get request to an api', () => { - return callThunk(oAuthValidate, oauthData).then(() => { + it('should send get request to an api', () => + callThunk(oAuthValidate, oauthData).then(() => { expect(request.get, 'to have a call satisfying', ['/api/oauth2/v1/validate', {}]); - }); - }); + }) + ); - it('should dispatch setClient, setOAuthRequest and setScopes', () => { - return callThunk(oAuthValidate, oauthData).then(() => { + it('should dispatch setClient, setOAuthRequest and setScopes', () => + callThunk(oAuthValidate, oauthData).then(() => { expectDispatchCalls([ [setClient(resp.client)], - [setOAuthRequest(resp.oAuth)], + [setOAuthRequest({ + ...resp.oAuth, + prompt: 'none', + loginHint: undefined + })], [setScopes(resp.session.scopes)] ]); - }); - }); + }) + ); }); describe('#oAuthComplete()', () => { @@ -100,7 +106,7 @@ describe('components/auth/actions', () => { return callThunk(oAuthComplete).then(() => { expect(request.post, 'to have a call satisfying', [ - '/api/oauth2/v1/complete?client_id=&redirect_uri=&response_type=&description=&scope=&state=', + '/api/oauth2/v1/complete?client_id=&redirect_uri=&response_type=&description=&scope=&prompt=&login_hint=&state=', {} ]); }); @@ -160,4 +166,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')] + ]); + }) + ); + }); + }); }); diff --git a/tests/components/auth/reducer.test.js b/tests/components/auth/reducer.test.js new file mode 100644 index 0000000..0cb40ab --- /dev/null +++ b/tests/components/auth/reducer.test.js @@ -0,0 +1,43 @@ +import expect from 'unexpected'; + +import auth from 'components/auth/reducer'; +import { + setLogin, SET_LOGIN, + setAccountSwitcher, SET_SWITCHER +} from 'components/auth/actions'; + +describe('components/auth/reducer', () => { + describe(SET_LOGIN, () => { + it('should set login', () => { + const expectedLogin = 'foo'; + + expect(auth(undefined, setLogin(expectedLogin)), 'to satisfy', { + login: expectedLogin + }); + }); + }); + + describe(SET_SWITCHER, () => { + it('should be enabled by default', () => + expect(auth(undefined, {}), 'to satisfy', { + isSwitcherEnabled: true + }) + ); + + it('should enable switcher', () => { + const expectedValue = true; + + expect(auth(undefined, setAccountSwitcher(expectedValue)), 'to satisfy', { + isSwitcherEnabled: expectedValue + }); + }); + + it('should disable switcher', () => { + const expectedValue = false; + + expect(auth(undefined, setAccountSwitcher(expectedValue)), 'to satisfy', { + isSwitcherEnabled: expectedValue + }); + }); + }); +}); diff --git a/tests/components/user/actions.test.js b/tests/components/user/actions.test.js index 003cebd..693dce4 100644 --- a/tests/components/user/actions.test.js +++ b/tests/components/user/actions.test.js @@ -3,6 +3,7 @@ import expect from 'unexpected'; import { routeActions } from 'react-router-redux'; import request from 'services/request'; +import { reset, RESET } from 'components/accounts/actions'; import { logout, @@ -11,8 +12,10 @@ import { describe('components/user/actions', () => { - const dispatch = sinon.stub().named('dispatch'); - const getState = sinon.stub().named('getState'); + const getState = sinon.stub().named('store.getState'); + const dispatch = sinon.spy((arg) => + typeof arg === 'function' ? arg(dispatch, getState) : arg + ).named('store.dispatch'); const callThunk = function(fn, ...args) { const thunk = fn(...args); @@ -39,11 +42,16 @@ describe('components/user/actions', () => { }); describe('user with jwt', () => { + const token = 'iLoveRockNRoll'; + beforeEach(() => { getState.returns({ user: { - token: 'iLoveRockNRoll', lang: 'foo' + }, + accounts: { + active: {token}, + available: [{token}] } }); }); @@ -62,20 +70,27 @@ describe('components/user/actions', () => { return callThunk(logout).then(() => { expect(request.post, 'to have a call satisfying', [ - '/api/authentication/logout' + '/api/authentication/logout', {}, {} ]); }); }); testChangedToGuest(); + testAccountsReset(); testRedirectedToLogin(); }); - describe('user without jwt', () => { // (a guest with partially filled user's state) + describe('user without jwt', () => { + // (a guest with partially filled user's state) + // DEPRECATED beforeEach(() => { getState.returns({ user: { lang: 'foo' + }, + accounts: { + active: null, + available: [] } }); }); @@ -87,6 +102,7 @@ describe('components/user/actions', () => { ); testChangedToGuest(); + testAccountsReset(); testRedirectedToLogin(); }); @@ -112,5 +128,15 @@ describe('components/user/actions', () => { }) ); } + + function testAccountsReset() { + it(`should dispatch ${RESET}`, () => + callThunk(logout).then(() => { + expect(dispatch, 'to have a call satisfying', [ + reset() + ]); + }) + ); + } }); }); diff --git a/tests/components/user/middlewares/bearerHeaderMiddleware.test.js b/tests/components/user/middlewares/bearerHeaderMiddleware.test.js index 0d2ea5e..f9dc8ef 100644 --- a/tests/components/user/middlewares/bearerHeaderMiddleware.test.js +++ b/tests/components/user/middlewares/bearerHeaderMiddleware.test.js @@ -3,31 +3,77 @@ import expect from 'unexpected'; import bearerHeaderMiddleware from 'components/user/middlewares/bearerHeaderMiddleware'; describe('bearerHeaderMiddleware', () => { - it('should set Authorization header', () => { + const emptyState = { + user: {}, + accounts: { + active: null + } + }; + + describe('when token available', () => { const token = 'foo'; const middleware = bearerHeaderMiddleware({ getState: () => ({ + ...emptyState, + accounts: { + active: {token} + } + }) + }); + + it('should set Authorization header', () => { + const data = { + options: { + headers: {} + } + }; + + middleware.before(data); + + expectBearerHeader(data, token); + }); + + it('overrides user.token with options.token if available', () => { + const tokenOverride = 'tokenOverride'; + const data = { + options: { + headers: {}, + token: tokenOverride + } + }; + + middleware.before(data); + + expectBearerHeader(data, tokenOverride); + }); + }); + + describe('when legacy token available', () => { + const token = 'foo'; + const middleware = bearerHeaderMiddleware({ + getState: () => ({ + ...emptyState, user: {token} }) }); - const data = { - options: { - headers: {} - } - }; + it('should set Authorization header', () => { + const data = { + options: { + headers: {} + } + }; - middleware.before(data); + middleware.before(data); - expect(data.options.headers, 'to satisfy', { - Authorization: `Bearer ${token}` + expectBearerHeader(data, token); }); }); it('should not set Authorization header if no token', () => { const middleware = bearerHeaderMiddleware({ getState: () => ({ - user: {} + ...emptyState }) }); @@ -41,4 +87,10 @@ describe('bearerHeaderMiddleware', () => { expect(data.options.headers.Authorization, 'to be undefined'); }); + + function expectBearerHeader(data, token) { + expect(data.options.headers, 'to satisfy', { + Authorization: `Bearer ${token}` + }); + } }); diff --git a/tests/components/user/middlewares/refreshTokenMiddleware.test.js b/tests/components/user/middlewares/refreshTokenMiddleware.test.js index bd8559c..9b9aa3d 100644 --- a/tests/components/user/middlewares/refreshTokenMiddleware.test.js +++ b/tests/components/user/middlewares/refreshTokenMiddleware.test.js @@ -3,6 +3,7 @@ import expect from 'unexpected'; import refreshTokenMiddleware from 'components/user/middlewares/refreshTokenMiddleware'; import authentication from 'services/api/authentication'; +import { updateToken } from 'components/accounts/actions'; const refreshToken = 'foo'; const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0NzA3NjE0NDMsImV4cCI6MTQ3MDc2MTQ0MywiaWF0IjoxNDcwNzYxNDQzLCJqdGkiOiJpZDEyMzQ1NiJ9.gWdnzfQQvarGpkbldUvB8qdJZSVkvdNtCbhbbl2yJW8'; @@ -16,46 +17,170 @@ describe('refreshTokenMiddleware', () => { beforeEach(() => { sinon.stub(authentication, 'requestToken').named('authentication.requestToken'); + sinon.stub(authentication, 'logout').named('authentication.logout'); getState = sinon.stub().named('store.getState'); - dispatch = sinon.stub().named('store.dispatch'); + dispatch = sinon.spy((arg) => + typeof arg === 'function' ? arg(dispatch, getState) : arg + ).named('store.dispatch'); middleware = refreshTokenMiddleware({getState, dispatch}); }); afterEach(() => { authentication.requestToken.restore(); + authentication.logout.restore(); }); + it('must be till 2100 to test with validToken', () => + expect(new Date().getFullYear(), 'to be less than', 2100) + ); + describe('#before', () => { - it('should request new token', () => { - getState.returns({ - user: { + describe('when token expired', () => { + beforeEach(() => { + const account = { token: expiredToken, refreshToken - } + }; + getState.returns({ + accounts: { + active: account, + available: [account] + }, + user: {} + }); }); - const data = { - url: 'foo', - options: { - headers: {} - } - }; + it('should request new token', () => { + const data = { + url: 'foo', + options: { + headers: {} + } + }; - authentication.requestToken.returns(Promise.resolve({token: validToken})); + authentication.requestToken.returns(Promise.resolve({token: validToken})); + + return middleware.before(data).then((resp) => { + expect(resp, 'to satisfy', data); + + expect(authentication.requestToken, 'to have a call satisfying', [ + refreshToken + ]); + }); + }); + + it('should not apply to refresh-token request', () => { + const data = {url: '/refresh-token', options: {}}; + const resp = middleware.before(data); - return middleware.before(data).then((resp) => { expect(resp, 'to satisfy', data); - expect(authentication.requestToken, 'to have a call satisfying', [ + expect(authentication.requestToken, 'was not called'); + }); + + it('should not auto refresh token if options.token specified', () => { + const data = { + url: 'foo', + options: {token: 'foo'} + }; + middleware.before(data); + + expect(authentication.requestToken, 'was not called'); + }); + + it('should update user with new token', () => { + const data = { + url: 'foo', + options: { + headers: {} + } + }; + + authentication.requestToken.returns(Promise.resolve({token: validToken})); + + return middleware.before(data).then(() => + expect(dispatch, 'to have a call satisfying', [ + updateToken(validToken) + ]) + ); + }); + + it('should if token can not be parsed', () => { + const account = { + token: 'realy bad token', refreshToken - ]); + }; + getState.returns({ + accounts: { + active: account, + available: [account] + }, + user: {} + }); + + const req = {url: 'foo', options: {}}; + + return expect(middleware.before(req), 'to be fulfilled with', req).then(() => { + expect(authentication.requestToken, 'was not called'); + + expect(dispatch, 'to have a call satisfying', [ + {payload: {isGuest: true}} + ]); + }); + }); + + it('should logout if token request failed', () => { + authentication.requestToken.returns(Promise.reject()); + + return expect(middleware.before({url: 'foo', options: {}}), 'to be fulfilled').then(() => + expect(dispatch, 'to have a call satisfying', [ + {payload: {isGuest: true}} + ]) + ); + }); + }); + + describe('when token expired legacy user', () => { + beforeEach(() => { + getState.returns({ + accounts: { + active: null, + available: [] + }, + user: { + token: expiredToken, + refreshToken + } + }); + }); + + it('should request new token', () => { + const data = { + url: 'foo', + options: { + headers: {} + } + }; + + authentication.requestToken.returns(Promise.resolve({token: validToken})); + + return middleware.before(data).then((resp) => { + expect(resp, 'to satisfy', data); + + expect(authentication.requestToken, 'to have a call satisfying', [ + refreshToken + ]); + }); }); }); it('should not be applied if no token', () => { getState.returns({ + accounts: { + active: null + }, user: {} }); @@ -66,75 +191,124 @@ describe('refreshTokenMiddleware', () => { expect(authentication.requestToken, 'was not called'); }); - - it('should not apply to refresh-token request', () => { - getState.returns({ - user: {} - }); - - const data = {url: '/refresh-token'}; - const resp = middleware.before(data); - - expect(resp, 'to satisfy', data); - - expect(authentication.requestToken, 'was not called'); - }); - - xit('should update user with new token'); // TODO: need a way to test, that action was called - xit('should logout if invalid token'); // TODO: need a way to test, that action was called - - xit('should logout if token request failed', () => { - getState.returns({ - user: { - token: expiredToken, - refreshToken - } - }); - - authentication.requestToken.returns(Promise.reject()); - - return middleware.before({url: 'foo'}).then((resp) => { - // TODO: need a way to test, that action was called - expect(dispatch, 'to have a call satisfying', logout); - }); - }); }); describe('#catch', () => { - it('should request new token', () => { + const expiredResponse = { + name: 'Unauthorized', + message: 'Token expired', + code: 0, + status: 401, + type: 'yii\\web\\UnauthorizedHttpException' + }; + + const badTokenReponse = { + name: 'Unauthorized', + message: 'You are requesting with an invalid credential.', + code: 0, + status: 401, + type: 'yii\\web\\UnauthorizedHttpException' + }; + + const incorrectTokenReponse = { + name: 'Unauthorized', + message: 'Incorrect token', + code: 0, + status: 401, + type: 'yii\\web\\UnauthorizedHttpException' + }; + + let restart; + + beforeEach(() => { getState.returns({ - user: { - refreshToken - } + accounts: { + active: {refreshToken}, + available: [{refreshToken}] + }, + user: {} }); - const restart = sinon.stub().named('restart'); + restart = sinon.stub().named('restart'); authentication.requestToken.returns(Promise.resolve({token: validToken})); + }); - return middleware.catch({ - status: 401, - message: 'Token expired' - }, restart).then(() => { + it('should request new token if expired', () => + middleware.catch(expiredResponse, {options: {}}, restart).then(() => { expect(authentication.requestToken, 'to have a call satisfying', [ refreshToken ]); expect(restart, 'was called'); + }) + ); + + it('should logout user if invalid credential', () => + expect( + middleware.catch(badTokenReponse, {options: {}}, restart), + 'to be rejected' + ).then(() => + expect(dispatch, 'to have a call satisfying', [ + {payload: {isGuest: true}} + ]) + ) + ); + + it('should logout user if token is incorrect', () => + expect( + middleware.catch(incorrectTokenReponse, {options: {}}, restart), + 'to be rejected' + ).then(() => + expect(dispatch, 'to have a call satisfying', [ + {payload: {isGuest: true}} + ]) + ) + ); + + it('should pass the request through if options.token specified', () => { + const promise = middleware.catch(expiredResponse, { + options: { + token: 'foo' + } + }, restart); + + return expect(promise, 'to be rejected with', expiredResponse).then(() => { + expect(restart, 'was not called'); + expect(authentication.requestToken, 'was not called'); }); }); - xit('should logout user if token cannot be refreshed'); // TODO: need a way to test, that action was called - it('should pass the rest of failed requests through', () => { const resp = {}; - const promise = middleware.catch(resp); + const promise = middleware.catch(resp, { + options: {} + }, restart); - expect(promise, 'to be rejected'); - - return promise.catch((actual) => { - expect(actual, 'to be', resp); + return expect(promise, 'to be rejected with', resp).then(() => { + expect(restart, 'was not called'); + expect(authentication.requestToken, 'was not called'); }); }); + + describe('legacy user.refreshToken', () => { + beforeEach(() => { + getState.returns({ + accounts: { + active: null + }, + user: {refreshToken} + }); + }); + + it('should request new token if expired', () => + middleware.catch(expiredResponse, {options: {}}, restart).then(() => { + expect(authentication.requestToken, 'to have a call satisfying', [ + refreshToken + ]); + expect(restart, 'was called'); + }) + ); + }); }); }); diff --git a/tests/services/api/authentication.test.js b/tests/services/api/authentication.test.js new file mode 100644 index 0000000..6f054f9 --- /dev/null +++ b/tests/services/api/authentication.test.js @@ -0,0 +1,126 @@ +import expect from 'unexpected'; + +import request from 'services/request'; +import authentication from 'services/api/authentication'; +import accounts from 'services/api/accounts'; + +describe('authentication api', () => { + describe('#validateToken()', () => { + const validTokens = {token: 'foo', refreshToken: 'bar'}; + + beforeEach(() => { + sinon.stub(accounts, 'current'); + + accounts.current.returns(Promise.resolve()); + }); + + afterEach(() => { + accounts.current.restore(); + }); + + it('should request accounts.current', () => + expect(authentication.validateToken(validTokens), 'to be fulfilled') + .then(() => { + expect(accounts.current, 'to have a call satisfying', [ + {token: 'foo'} + ]); + }) + ); + + it('should resolve with both tokens', () => + expect(authentication.validateToken(validTokens), 'to be fulfilled with', validTokens) + ); + + it('rejects if token has a bad type', () => + expect(authentication.validateToken({token: {}}), + 'to be rejected with', 'token must be a string' + ) + ); + + it('rejects if refreshToken has a bad type', () => + expect(authentication.validateToken({token: 'foo', refreshToken: {}}), + 'to be rejected with', 'refreshToken must be a string' + ) + ); + + it('rejects if accounts.current request is unexpectedly failed', () => { + const error = 'Something wrong'; + accounts.current.returns(Promise.reject(error)); + + return expect(authentication.validateToken(validTokens), + 'to be rejected with', error + ); + }); + + describe('when token is expired', () => { + const expiredResponse = { + name: 'Unauthorized', + message: 'Token expired', + code: 0, + status: 401, + type: 'yii\\web\\UnauthorizedHttpException' + }; + const newToken = 'baz'; + + beforeEach(() => { + sinon.stub(authentication, 'requestToken'); + + accounts.current.returns(Promise.reject(expiredResponse)); + authentication.requestToken.returns(Promise.resolve({token: newToken})); + }); + + afterEach(() => { + authentication.requestToken.restore(); + }); + + it('resolves with new token', () => + expect(authentication.validateToken(validTokens), + 'to be fulfilled with', {...validTokens, token: newToken} + ) + ); + + it('rejects if token request failed', () => { + const error = 'Something wrong'; + authentication.requestToken.returns(Promise.reject(error)); + + return expect(authentication.validateToken(validTokens), + 'to be rejected with', error + ); + }); + }); + }); + + describe('#logout', () => { + beforeEach(() => { + sinon.stub(request, 'post').named('request.post'); + }); + + afterEach(() => { + request.post.restore(); + }); + + it('should request logout api', () => { + authentication.logout(); + + expect(request.post, 'to have a call satisfying', [ + '/api/authentication/logout', {}, {} + ]); + }); + + it('returns a promise', () => { + request.post.returns(Promise.resolve()); + + return expect(authentication.logout(), 'to be fulfilled'); + }); + + it('overrides token if provided', () => { + const token = 'foo'; + + authentication.logout({token}); + + expect(request.post, 'to have a call satisfying', [ + '/api/authentication/logout', {}, {token} + ]); + }); + }); +}); diff --git a/tests/services/authFlow/AuthFlow.functional.test.js b/tests/services/authFlow/AuthFlow.functional.test.js index 9d135d5..451c665 100644 --- a/tests/services/authFlow/AuthFlow.functional.test.js +++ b/tests/services/authFlow/AuthFlow.functional.test.js @@ -47,6 +47,9 @@ describe('AuthFlow.functional', () => { state.user = { isGuest: true }; + state.auth = { + login: null + }; }); it('should redirect guest / -> /login', () => { @@ -81,7 +84,8 @@ describe('AuthFlow.functional', () => { auth: { oauth: { - clientId: 123 + clientId: 123, + prompt: [] } } }); diff --git a/tests/services/authFlow/AuthFlow.test.js b/tests/services/authFlow/AuthFlow.test.js index 3c3fe82..7d00a2c 100644 --- a/tests/services/authFlow/AuthFlow.test.js +++ b/tests/services/authFlow/AuthFlow.test.js @@ -267,6 +267,7 @@ describe('AuthFlow', () => { '/password': LoginState, '/accept-rules': LoginState, '/oauth/permissions': LoginState, + '/oauth/choose-account': LoginState, '/oauth/finish': LoginState, '/oauth2/v1/foo': OAuthState, '/oauth2/v1': OAuthState, diff --git a/tests/services/authFlow/ChooseAccountState.test.js b/tests/services/authFlow/ChooseAccountState.test.js new file mode 100644 index 0000000..58f2021 --- /dev/null +++ b/tests/services/authFlow/ChooseAccountState.test.js @@ -0,0 +1,56 @@ +import ChooseAccountState from 'services/authFlow/ChooseAccountState'; +import CompleteState from 'services/authFlow/CompleteState'; +import LoginState from 'services/authFlow/LoginState'; + +import { bootstrap, expectState, expectNavigate, expectRun } from './helpers'; + +describe('ChooseAccountState', () => { + let state; + let context; + let mock; + + beforeEach(() => { + state = new ChooseAccountState(); + + const data = bootstrap(); + context = data.context; + mock = data.mock; + }); + + afterEach(() => { + mock.verify(); + }); + + describe('#enter', () => { + it('should navigate to /oauth/choose-account', () => { + expectNavigate(mock, '/oauth/choose-account'); + + state.enter(context); + }); + }); + + 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); + + state.resolve(context, {}); + }); + }); + + describe('#reject', () => { + it('should logout', () => { + expectRun(mock, 'logout'); + + state.reject(context); + }); + }); +}); diff --git a/tests/services/authFlow/CompleteState.test.js b/tests/services/authFlow/CompleteState.test.js index b68b834..b8149e5 100644 --- a/tests/services/authFlow/CompleteState.test.js +++ b/tests/services/authFlow/CompleteState.test.js @@ -144,7 +144,8 @@ describe('CompleteState', () => { }, auth: { oauth: { - clientId: 'ely.by' + clientId: 'ely.by', + prompt: [] } } }); @@ -166,7 +167,8 @@ describe('CompleteState', () => { }, auth: { oauth: { - clientId: 'ely.by' + clientId: 'ely.by', + prompt: [] } } }); @@ -194,7 +196,8 @@ describe('CompleteState', () => { }, auth: { oauth: { - clientId: 'ely.by' + clientId: 'ely.by', + prompt: [] } } }); @@ -225,7 +228,8 @@ describe('CompleteState', () => { }, auth: { oauth: { - clientId: 'ely.by' + clientId: 'ely.by', + prompt: [] } } }); @@ -242,21 +246,21 @@ describe('CompleteState', () => { return promise.catch(mock.verify.bind(mock)); }; - it('should transition to finish state if rejected with static_page', () => { - return testOAuth('resolve', {redirectUri: 'static_page'}, FinishState); - }); + it('should transition to finish state if rejected with static_page', () => + testOAuth('resolve', {redirectUri: 'static_page'}, FinishState) + ); - it('should transition to finish state if rejected with static_page_with_code', () => { - return testOAuth('resolve', {redirectUri: 'static_page_with_code'}, FinishState); - }); + it('should transition to finish state if rejected with static_page_with_code', () => + testOAuth('resolve', {redirectUri: 'static_page_with_code'}, FinishState) + ); - it('should transition to login state if rejected with unauthorized', () => { - return testOAuth('reject', {unauthorized: true}, LoginState); - }); + it('should transition to login state if rejected with unauthorized', () => + testOAuth('reject', {unauthorized: true}, LoginState) + ); - it('should transition to permissions state if rejected with acceptRequired', () => { - return testOAuth('reject', {acceptRequired: true}, PermissionsState); - }); + it('should transition to permissions state if rejected with acceptRequired', () => + testOAuth('reject', {acceptRequired: true}, PermissionsState) + ); }); describe('permissions accept', () => { @@ -285,7 +289,8 @@ describe('CompleteState', () => { }, auth: { oauth: { - clientId: 'ely.by' + clientId: 'ely.by', + prompt: [] } } }); @@ -309,7 +314,8 @@ describe('CompleteState', () => { }, auth: { oauth: { - clientId: 'ely.by' + clientId: 'ely.by', + prompt: [] } } }); @@ -337,6 +343,7 @@ describe('CompleteState', () => { auth: { oauth: { clientId: 'ely.by', + prompt: [], acceptRequired: true } } @@ -365,6 +372,7 @@ describe('CompleteState', () => { auth: { oauth: { clientId: 'ely.by', + prompt: [], acceptRequired: true } } diff --git a/tests/services/authFlow/LoginState.test.js b/tests/services/authFlow/LoginState.test.js index 60ad0b3..54602a5 100644 --- a/tests/services/authFlow/LoginState.test.js +++ b/tests/services/authFlow/LoginState.test.js @@ -1,6 +1,5 @@ import LoginState from 'services/authFlow/LoginState'; import PasswordState from 'services/authFlow/PasswordState'; -import ForgotPasswordState from 'services/authFlow/ForgotPasswordState'; import { bootstrap, expectState, expectNavigate, expectRun } from './helpers'; @@ -24,7 +23,8 @@ describe('LoginState', () => { describe('#enter', () => { it('should navigate to /login', () => { context.getState.returns({ - user: {isGuest: true} + user: {isGuest: true}, + auth: {login: null} }); expectNavigate(mock, '/login'); @@ -32,22 +32,15 @@ describe('LoginState', () => { state.enter(context); }); - const testTransitionToPassword = (user) => { + it('should transition to password if login was set', () => { context.getState.returns({ - user: user + user: {isGuest: true}, + auth: {login: 'foo'} }); expectState(mock, PasswordState); 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'}); }); }); diff --git a/tests/services/authFlow/OAuthState.test.js b/tests/services/authFlow/OAuthState.test.js index f418b55..25f660f 100644 --- a/tests/services/authFlow/OAuthState.test.js +++ b/tests/services/authFlow/OAuthState.test.js @@ -28,6 +28,8 @@ describe('OAuthState', () => { response_type: 'response_type', description: 'description', scope: 'scope', + prompt: 'none', + login_hint: 1, state: 'state' }; @@ -42,6 +44,8 @@ describe('OAuthState', () => { responseType: query.response_type, description: query.description, scope: query.scope, + prompt: query.prompt, + loginHint: query.login_hint, state: query.state }) ).returns({then() {}}); diff --git a/tests/services/authFlow/PasswordState.test.js b/tests/services/authFlow/PasswordState.test.js index 5a003de..b92578a 100644 --- a/tests/services/authFlow/PasswordState.test.js +++ b/tests/services/authFlow/PasswordState.test.js @@ -25,7 +25,8 @@ describe('PasswordState', () => { describe('#enter', () => { it('should navigate to /password', () => { context.getState.returns({ - user: {isGuest: true} + user: {isGuest: true}, + auth: {login: 'foo'} }); expectNavigate(mock, '/password'); @@ -35,7 +36,8 @@ describe('PasswordState', () => { it('should transition to complete if not guest', () => { context.getState.returns({ - user: {isGuest: false} + user: {isGuest: false}, + auth: {login: null} }); expectState(mock, CompleteState); @@ -45,42 +47,29 @@ describe('PasswordState', () => { }); describe('#resolve', () => { - (function() { - const expectedLogin = 'login'; - const expectedPassword = 'password'; + it('should call login with login and password', () => { + const expectedLogin = 'foo'; + const expectedPassword = 'bar'; const expectedRememberMe = true; - const testWith = (user) => { - it(`should call login with email or username and password. User: ${JSON.stringify(user)}`, () => { - context.getState.returns({user}); - - expectRun( - mock, - 'login', - sinon.match({ - login: expectedLogin, - password: expectedPassword, - rememberMe: expectedRememberMe, - }) - ).returns({then() {}}); - - state.resolve(context, {password: expectedPassword, rememberMe: expectedRememberMe}); - }); - }; - - testWith({ - email: expectedLogin + context.getState.returns({ + auth: { + login: expectedLogin + } }); - testWith({ - username: expectedLogin - }); + expectRun( + mock, + 'login', + sinon.match({ + login: expectedLogin, + password: expectedPassword, + rememberMe: expectedRememberMe, + }) + ).returns({then() {}}); - testWith({ - email: expectedLogin, - username: expectedLogin - }); - }()); + state.resolve(context, {password: expectedPassword, rememberMe: expectedRememberMe}); + }); it('should transition to complete state on successfull login', () => { const promise = Promise.resolve(); @@ -88,8 +77,8 @@ describe('PasswordState', () => { const expectedPassword = 'password'; context.getState.returns({ - user: { - email: expectedLogin + auth: { + login: expectedLogin } }); @@ -111,8 +100,8 @@ describe('PasswordState', () => { }); describe('#goBack', () => { - it('should transition to forgot password state', () => { - expectRun(mock, 'logout'); + it('should transition to login state', () => { + expectRun(mock, 'setLogin', null); expectState(mock, LoginState); state.goBack(context); diff --git a/webpack.config.js b/webpack.config.js index 2fa1959..d0a4a70 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -6,6 +6,7 @@ const webpack = require('webpack'); const loaderUtils = require('loader-utils'); const ExtractTextPlugin = require('extract-text-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); +const CircularDependencyPlugin = require('circular-dependency-plugin'); const cssUrl = require('webpack-utils/cssUrl'); const cssImport = require('postcss-import'); @@ -247,7 +248,11 @@ if (isProduction) { if (!isProduction && !isTest) { webpackConfig.plugins.push( new webpack.HotModuleReplacementPlugin(), - new webpack.NoErrorsPlugin() + new webpack.NoErrorsPlugin(), + new CircularDependencyPlugin({ + exclude: /node_modules/, + failOnError: false + }) ); if (config.apiHost) {