Merge branch '48-multy-acc' into develop

This commit is contained in:
SleepWalker 2016-11-19 16:49:01 +02:00
commit 63ab3d58e8
75 changed files with 2413 additions and 425 deletions

View File

@ -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",

View File

@ -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');

View File

@ -0,0 +1,5 @@
{
"addAccount": "Add account",
"goToEly": "Go to Ely.by profile",
"logout": "Log out"
}

View File

@ -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 (
<div className={classNames(
styles.accountSwitcher,
styles[`${skin}AccountSwitcher`],
)}>
{highlightActiveAccount ? (
<div className={styles.item}>
<div className={classNames(
styles.accountIcon,
styles.activeAccountIcon,
styles.accountIcon1
)} />
<div className={styles.activeAccountInfo}>
<div className={styles.activeAccountUsername}>
{accounts.active.username}
</div>
<div className={classNames(styles.accountEmail, styles.activeAccountEmail)}>
{accounts.active.email}
</div>
<div className={styles.links}>
<div className={styles.link}>
<a href={`http://ely.by/u${accounts.active.id}`} target="_blank">
<Message {...messages.goToEly} />
</a>
</div>
<div className={styles.link}>
<a className={styles.link} onClick={this.onRemove(accounts.active)} href="#">
<Message {...messages.logout} />
</a>
</div>
</div>
</div>
</div>
) : null}
{available.map((account, id) => (
<div className={classNames(styles.item, styles.accountSwitchItem)}
key={account.id}
onClick={this.onSwitch(account)}
>
<div className={classNames(
styles.accountIcon,
styles[`accountIcon${id % 7 + (highlightActiveAccount ? 2 : 1)}`]
)} />
{allowLogout ? (
<div className={styles.logoutIcon} onClick={this.onRemove(account)} />
) : (
<div className={styles.nextIcon} />
)}
<div className={styles.accountInfo}>
<div className={styles.accountUsername}>
{account.username}
</div>
<div className={styles.accountEmail}>
{account.email}
</div>
</div>
</div>
))}
{allowAdd ? (
<Link to="/login" onClick={this.props.onAfterAction}>
<Button
color={COLOR_WHITE}
block
small
className={styles.addAccount}
label={
<Message {...messages.addAccount}>
{(message) =>
<span>
<div className={styles.addIcon} />
{message}
</span>
}
</Message>
}
/>
</Link>
) : null}
</div>
);
}
onSwitch = (account) => (event) => {
event.preventDefault();
loader.show();
this.props.switchAccount(account)
.then(() => this.props.onAfterAction())
.then(() => this.props.onSwitch(account))
.finally(() => loader.hide());
};
onRemove = (account) => (event) => {
event.preventDefault();
event.stopPropagation();
this.props.removeAccount(account)
.then(() => this.props.onAfterAction());
};
}
import { connect } from 'react-redux';
import { authenticate, revoke } from 'components/accounts/actions';
export default connect(({accounts, user}) => ({
accounts,
userLang: user.lang // this is to force re-render on lang change
}), {
switchAccount: authenticate,
removeAccount: revoke
})(AccountSwitcher);

View File

@ -0,0 +1,238 @@
@import '~components/ui/colors.scss';
@import '~components/ui/fonts.scss';
// TODO: эту константу можно заимпортить из panel.scss, но это приводит к странным ошибкам
//@import '~components/ui/panel.scss';
$bodyLeftRightPadding: 20px;
$lightBorderColor: #eee;
.accountSwitcher {
text-align: left;
}
.accountInfo {
}
.accountUsername,
.accountEmail {
overflow: hidden;
text-overflow: ellipsis;
}
.lightAccountSwitcher {
background: #fff;
color: #444;
width: 205px;
$border: 1px solid $lightBorderColor;
border-left: $border;
border-right: $border;
border-bottom: 7px solid darker($green);
.item {
padding: 15px;
border-bottom: 1px solid $lightBorderColor;
}
.accountSwitchItem {
cursor: pointer;
transition: .25s;
&:hover {
background-color: $whiteButtonLight;
}
&:active {
background-color: $whiteButtonDark;
}
}
.accountIcon {
font-size: 27px;
width: 20px;
text-align: center;
}
.activeAccountIcon {
font-size: 40px;
}
.activeAccountInfo {
margin-left: 29px;
}
.activeAccountUsername {
font-family: $font-family-title;
font-size: 20px;
color: $green;
}
.activeAccountEmail {
}
.links {
margin-top: 6px;
}
.link {
font-size: 12px;
margin-bottom: 3px;
&:last-of-type {
margin-bottom: 0;
}
a {
color: #666;
font-size: 12px;
border-bottom: 1px dotted #666;
text-decoration: none;
transition: .25s;
&:hover {
border-bottom-color: #aaa;
color: #777;
}
}
}
.accountInfo {
margin-left: 29px;
margin-right: 25px;
}
.accountUsername {
font-family: $font-family-title;
font-size: 14px;
color: #666;
}
.accountEmail {
font-size: 10px;
color: #999;
}
.addAccount {
}
}
.darkAccountSwitcher {
background: $black;
$border: 1px solid lighter($black);
.item {
padding: 15px 20px;
border-top: 1px solid lighter($black);
transition: .25s;
cursor: pointer;
&:hover {
background-color: lighter($black);
}
&:active {
background-color: darker($black);
}
&:last-of-type {
border-bottom: $border;
}
}
.accountIcon {
font-size: 35px;
}
.accountInfo {
margin-left: 30px;
margin-right: 26px;
}
.accountUsername {
font-family: $font-family-title;
color: #fff;
}
.accountEmail {
color: #666;
font-size: 12px;
}
}
.accountIcon {
composes: minecraft-character from 'components/ui/icons.scss';
float: left;
&1 {
color: $green;
}
&2 {
color: $blue;
}
&3 {
color: $violet;
}
&4 {
color: $orange;
}
&5 {
color: $dark_blue;
}
&6 {
color: $light_violet;
}
&7 {
color: $red;
}
}
.addIcon {
composes: plus from 'components/ui/icons.scss';
color: $green;
position: relative;
bottom: 1px;
margin-right: 3px;
}
.nextIcon {
composes: arrowRight from 'components/ui/icons.scss';
position: relative;
float: right;
font-size: 24px;
color: #4E4E4E;
line-height: 35px;
left: 0;
transition: color .25s, left .5s;
.item:hover & {
color: #aaa;
left: 5px;
}
}
.logoutIcon {
composes: exit from 'components/ui/icons.scss';
color: #cdcdcd;
float: right;
line-height: 27px;
transition: .25s;
&:hover {
color: #777;
}
}

View File

@ -0,0 +1,149 @@
import authentication from 'services/api/authentication';
import accounts from 'services/api/accounts';
import { updateUser, logout } from 'components/user/actions';
import { setLocale } from 'components/i18n/actions';
/**
* @typedef {object} Account
* @property {string} account.id
* @property {string} account.username
* @property {string} account.email
* @property {string} account.token
* @property {string} account.refreshToken
*/
/**
* @param {Account|object} account
* @param {string} account.token
* @param {string} account.refreshToken
*
* @return {function}
*/
export function authenticate({token, refreshToken}) {
return (dispatch) =>
authentication.validateToken({token, refreshToken})
.then(({token, refreshToken}) =>
accounts.current({token})
.then((user) => ({
user,
account: {
id: user.id,
username: user.username,
email: user.email,
token,
refreshToken
}
}))
)
.then(({user, account}) => {
dispatch(add(account));
dispatch(activate(account));
dispatch(updateUser({
isGuest: false,
...user
}));
return dispatch(setLocale(user.lang))
.then(() => account);
});
}
/**
* @param {Account} account
*
* @return {function}
*/
export function revoke(account) {
return (dispatch, getState) => {
const accountToReplace = getState().accounts.available.find(({id}) => id !== account.id);
if (accountToReplace) {
return dispatch(authenticate(accountToReplace))
.then(() => {
authentication.logout(account);
dispatch(remove(account));
});
}
return dispatch(logout());
};
}
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 function logoutAll() {
return (dispatch, getState) => {
const {accounts: {available}} = getState();
available.forEach((account) => authentication.logout(account));
dispatch(reset());
};
}
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
};
}

View File

@ -0,0 +1 @@
export AccountSwitcher from './AccountSwitcher';

View File

@ -0,0 +1,82 @@
import { ADD, REMOVE, ACTIVATE, RESET, UPDATE_TOKEN } from './actions';
/**
* @typedef {AccountsState}
* @property {Account} active
* @property {Account[]} available
*/
/**
* @param {AccountsState} state
* @param {string} options.type
* @param {object} options.payload
*
* @return {AccountsState}
*/
export default function accounts(
state = {
active: null,
available: []
},
{type, payload = {}}
) {
switch (type) {
case ADD:
if (!payload || !payload.id || !payload.token || !payload.refreshToken) {
throw new Error('Invalid or empty payload passed for accounts.add');
}
state.available = state.available
.filter((account) => account.id !== payload.id)
.concat(payload);
state.available.sort((account1, account2) => {
if (account1.username === account2.username) {
return 0;
}
return account1.username > account2.username ? 1 : -1;
});
return state;
case ACTIVATE:
if (!payload || !payload.id || !payload.token || !payload.refreshToken) {
throw new Error('Invalid or empty payload passed for accounts.add');
}
return {
...state,
active: payload
};
case RESET:
return accounts(undefined, {});
case REMOVE:
if (!payload || !payload.id) {
throw new Error('Invalid or empty payload passed for accounts.remove');
}
return {
...state,
available: state.available.filter((account) => account.id !== payload.id)
};
case UPDATE_TOKEN:
if (typeof payload !== 'string') {
throw new Error('payload must be a jwt token');
}
return {
...state,
active: {
...state.active,
token: payload
}
};
default:
return state;
}
}

View File

@ -33,7 +33,7 @@ const contexts = [
['login', 'password', 'forgotPassword', 'recoverPassword'],
['register', 'activation', 'resendActivation'],
['acceptRules'],
['permissions']
['chooseAccount', 'permissions']
];
if (process.env.NODE_ENV !== 'production') {
@ -64,10 +64,7 @@ class PanelTransition extends Component {
payload: PropTypes.object
})]),
isLoading: PropTypes.bool,
login: PropTypes.shape({
login: PropTypes.string,
password: PropTypes.string
})
login: PropTypes.string
}).isRequired,
user: userShape.isRequired,
setErrors: PropTypes.func.isRequired,
@ -89,12 +86,12 @@ class PanelTransition extends Component {
type: PropTypes.string,
payload: PropTypes.object
})]),
login: PropTypes.shape({
login: PropTypes.string,
password: PropTypes.string
})
login: PropTypes.string
}),
user: userShape,
accounts: PropTypes.shape({
available: PropTypes.array
}),
requestRedraw: PropTypes.func,
clearErrors: PropTypes.func,
resolve: PropTypes.func,
@ -314,7 +311,12 @@ class PanelTransition extends Component {
}
shouldMeasureHeight() {
return [this.props.auth.error, this.state.isHeightDirty, this.props.user.lang].join('');
return [
this.props.auth.error,
this.state.isHeightDirty,
this.props.user.lang,
this.props.accounts.available.length
].join('');
}
getHeader({key, style, data}) {
@ -446,12 +448,35 @@ class PanelTransition extends Component {
}
}
export default connect((state) => ({
user: state.user,
auth: state.auth,
resolve: authFlow.resolve.bind(authFlow),
reject: authFlow.reject.bind(authFlow)
}), {
export default connect((state) => {
const {login} = state.auth;
let user = {
...state.user
};
if (login) {
user = {
...user,
isGuest: true,
email: '',
username: ''
};
if (/[@.]/.test(login)) {
user.email = login;
} else {
user.username = login;
}
}
return {
user,
accounts: state.accounts, // need this, to re-render height
auth: state.auth,
resolve: authFlow.resolve.bind(authFlow),
reject: authFlow.reject.bind(authFlow)
};
}, {
clearErrors: actions.clearErrors,
setErrors: actions.setErrors
})(PanelTransition);

View File

@ -2,12 +2,12 @@
To add new panel you need to:
* add new state to `services/authFlow` and coresponding test to `tests/services/authFlow`
* connect state to `authFlow`. Update `services/authFlow/AuthFlow.test` and `services/authFlow/AuthFlow.functional.test` (the last one for some complex flow)
* add new actions to `components/auth/actions` and api endpoints to `services/api`
* create panel component at `components/auth/[panelId]`
* add new context in `components/auth/PanelTransition`
* connect component to `routes`
* add new state to `services/authFlow` and coresponding test to `tests/services/authFlow`
* connect state to `authFlow`. Update `services/authFlow/AuthFlow.test` and `services/authFlow/AuthFlow.functional.test` (the last one for some complex flow)
* add new actions to `components/auth/actions` and api endpoints to `services/api`
* whatever else you need
Commit id with example: f4d315c

View File

@ -1,6 +1,7 @@
import { routeActions } from 'react-router-redux';
import { updateUser, logout as logoutUser, acceptRules as userAcceptRules, authenticate } from 'components/user/actions';
import { updateUser, logout, acceptRules as userAcceptRules } from 'components/user/actions';
import { authenticate } from 'components/accounts/actions';
import authentication from 'services/api/authentication';
import oauth from 'services/api/oauth';
import signup from 'services/api/signup';
@ -19,24 +20,13 @@ export function login({login = '', password = '', rememberMe = false}) {
.catch((resp) => {
if (resp.errors) {
if (resp.errors.password === PASSWORD_REQUIRED) {
let username = '';
let email = '';
if (/[@.]/.test(login)) {
email = login;
} else {
username = login;
}
return dispatch(updateUser({
username,
email
}));
return dispatch(setLogin(login));
} else if (resp.errors.login === ACTIVATION_REQUIRED) {
return dispatch(needActivation());
} else if (resp.errors.login === LOGIN_REQUIRED && password) {
// TODO: log this case to backend
// return to the first step
dispatch(logout());
return dispatch(logout());
}
}
@ -125,7 +115,23 @@ export function resendActivation({email = '', captcha}) {
);
}
export const ERROR = 'error';
export const SET_LOGIN = 'auth:setLogin';
export function setLogin(login) {
return {
type: SET_LOGIN,
payload: login
};
}
export const SET_SWITCHER = 'auth:setAccountSwitcher';
export function setAccountSwitcher(isOn) {
return {
type: SET_SWITCHER,
payload: isOn
};
}
export const ERROR = 'auth:error';
export function setErrors(errors) {
return {
type: ERROR,
@ -138,9 +144,8 @@ export function clearErrors() {
return setErrors(null);
}
export function logout() {
return logoutUser();
}
export { logout, updateUser } from 'components/user/actions';
export { authenticate } from 'components/accounts/actions';
/**
* @param {object} oauthData
@ -149,6 +154,13 @@ export function logout() {
* @param {string} oauthData.responseType
* @param {string} oauthData.description
* @param {string} oauthData.scope
* @param {string} [oauthData.prompt='none'] - comma-separated list of values to adjust auth flow
* Posible values:
* * none - default behaviour
* * consent - forcibly prompt user for rules acceptance
* * select_account - force account choosage, even if user has only one
* @param {string} oauthData.loginHint - allows to choose the account, which will be used for auth
* The possible values: account id, email, username
* @param {string} oauthData.state
*
* @return {Promise}
@ -159,8 +171,17 @@ export function oAuthValidate(oauthData) {
return wrapInLoader((dispatch) =>
oauth.validate(oauthData)
.then((resp) => {
let prompt = (oauthData.prompt || 'none').split(',').map((item) => item.trim);
if (prompt.includes('none')) {
prompt = ['none'];
}
dispatch(setClient(resp.client));
dispatch(setOAuthRequest(resp.oAuth));
dispatch(setOAuthRequest({
...resp.oAuth,
prompt: oauthData.prompt || 'none',
loginHint: oauthData.loginHint
}));
dispatch(setScopes(resp.session.scopes));
localStorage.setItem('oauthData', JSON.stringify({ // @see services/authFlow/AuthFlow
timestamp: Date.now(),
@ -226,6 +247,13 @@ export function setClient({id, name, description}) {
};
}
export function resetOAuth() {
return (dispatch) => {
localStorage.removeItem('oauthData');
dispatch(setOAuthRequest({}));
};
}
export const SET_OAUTH = 'set_oauth';
export function setOAuthRequest(oauth) {
return {
@ -235,6 +263,8 @@ export function setOAuthRequest(oauth) {
redirectUrl: oauth.redirect_uri,
responseType: oauth.response_type,
scope: oauth.scope,
prompt: oauth.prompt,
loginHint: oauth.loginHint,
state: oauth.state
}
};
@ -305,7 +335,14 @@ function needActivation() {
}
function authHandler(dispatch) {
return (resp) => dispatch(authenticate(resp.access_token, resp.refresh_token));
return (resp) => dispatch(authenticate({
token: resp.access_token,
refreshToken: resp.refresh_token
})).then((resp) => {
dispatch(setLogin(null));
return resp;
});
}
function validationErrorsHandler(dispatch, repeatUrl) {

View File

@ -0,0 +1,6 @@
{
"chooseAccountTitle": "Choose an account",
"addAccount": "Log into another account",
"logoutAll": "Log out from all accounts",
"description": "You have logged in into multiple accounts. Please choose the one, you want to use to authorize {appName}"
}

View File

@ -0,0 +1,16 @@
import factory from 'components/auth/factory';
import messages from './ChooseAccount.intl.json';
import Body from './ChooseAccountBody';
export default factory({
title: messages.chooseAccountTitle,
body: Body,
footer: {
label: messages.addAccount
},
links: [
{
label: messages.logoutAll
}
]
});

View File

@ -0,0 +1,43 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import BaseAuthBody from 'components/auth/BaseAuthBody';
import { AccountSwitcher } from 'components/accounts';
import styles from './chooseAccount.scss';
import messages from './ChooseAccount.intl.json';
export default class ChooseAccountBody extends BaseAuthBody {
static displayName = 'ChooseAccountBody';
static panelId = 'chooseAccount';
render() {
const {client} = this.context.auth;
return (
<div>
{this.renderErrors()}
<div className={styles.description}>
<Message {...messages.description} values={{
appName: <span className={styles.appName}>{client.name}</span>
}} />
</div>
<div className={styles.accountSwitcherContainer}>
<AccountSwitcher
allowAdd={false}
allowLogout={false}
highlightActiveAccount={false}
onSwitch={this.onSwitch}
/>
</div>
</div>
);
}
onSwitch = (account) => {
this.context.resolve(account);
};
}

View File

@ -0,0 +1,23 @@
//@import '~components/ui/panel.scss';
// TODO: эту константу можно заимпортить из panel.scss, но это приводит к странным ошибкам
$bodyLeftRightPadding: 20px;
//@import '~components/ui/fonts.scss';
// TODO: эту константу можно заимпортить из fonts.scss, но это приводит к странным ошибкам
$font-family-title: 'Roboto Condensed', Arial, sans-serif;
.accountSwitcherContainer {
margin-left: -$bodyLeftRightPadding;
margin-right: -$bodyLeftRightPadding;
}
.description {
font-family: $font-family-title;
margin: 5px 0 19px;
line-height: 1.4;
font-size: 16px;
}
.appName {
color: #fff;
}

View File

@ -1,10 +1,22 @@
import { combineReducers } from 'redux';
import { ERROR, SET_CLIENT, SET_OAUTH, SET_OAUTH_RESULT, SET_SCOPES, SET_LOADING_STATE, REQUIRE_PERMISSIONS_ACCEPT } from './actions';
import {
ERROR,
SET_CLIENT,
SET_OAUTH,
SET_OAUTH_RESULT,
SET_SCOPES,
SET_LOADING_STATE,
REQUIRE_PERMISSIONS_ACCEPT,
SET_LOGIN,
SET_SWITCHER
} from './actions';
export default combineReducers({
login,
error,
isLoading,
isSwitcherEnabled,
client,
oauth,
scopes
@ -19,6 +31,7 @@ function error(
if (!error) {
throw new Error('Expected payload with error');
}
return payload;
default:
@ -26,6 +39,39 @@ function error(
}
}
function login(
state = null,
{type, payload = null}
) {
switch (type) {
case SET_LOGIN:
if (payload !== null && typeof payload !== 'string') {
throw new Error('Expected payload with login string or null');
}
return payload;
default:
return state;
}
}
function isSwitcherEnabled(
state = true,
{type, payload = false}
) {
switch (type) {
case SET_SWITCHER:
if (typeof payload !== 'boolean') {
throw new Error('Expected payload of boolean type');
}
return payload;
default:
return state;
}
}
function isLoading(
state = false,
@ -68,6 +114,8 @@ function oauth(
redirectUrl: payload.redirectUrl,
responseType: payload.responseType,
scope: payload.scope,
prompt: payload.prompt,
loginHint: payload.loginHint,
state: payload.state
};

View File

@ -1,18 +1,26 @@
import i18n from 'services/i18n';
import captcha from 'services/captcha';
export const SET_LOCALE = 'SET_LOCALE';
export const SET_LOCALE = 'i18n:setLocale';
export function setLocale(locale) {
return (dispatch) => i18n.require(
i18n.detectLanguage(locale)
).then(({locale, messages}) => {
dispatch({
type: SET_LOCALE,
payload: {
locale,
messages
}
});
dispatch(_setLocale({locale, messages}));
// TODO: probably should be moved from here, because it is a side effect
captcha.setLang(locale);
return locale;
});
}
function _setLocale({locale, messages}) {
return {
type: SET_LOCALE,
payload: {
locale,
messages
}
};
}

View File

@ -56,7 +56,7 @@
transition: .2s;
&:hover {
background: #f5f5f5;
background: $whiteButtonLight;
color: #262626;
}

View File

@ -81,7 +81,7 @@ $formColumnWidth: 416px;
.paramEditIcon {
composes: pencil from 'components/ui/icons.scss';
color: $light;
color: $white;
transition: .4s;
a:hover & {

View File

@ -3,6 +3,9 @@
}
.item {
// TODO: in some cases we do not need overflow hidden
// probably, it is better to create a separate class for children, that will
// enable overflow hidden and ellipsis
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;

View File

@ -42,7 +42,6 @@
}
}
// TODO: не уверен на счёт этого класса. Мб может лучше добавить это как класс-модификатор для .button?
.smallButton {
composes: button;
@ -52,20 +51,23 @@
line-height: 30px;
}
.black {
.white {
composes: button;
background-color: $black;
background-color: #fff;
color: #444;
&:hover {
background-color: $black-button-light;
color: #262626;
background-color: $whiteButtonLight;
}
&:active {
background-color: $black-button-dark;
background-color: $whiteButtonDark;
}
}
@include button-theme('black', $black);
@include button-theme('blue', $blue);
@include button-theme('green', $green);
@include button-theme('orange', $orange);

View File

@ -5,13 +5,13 @@ $violet: #6b5b8c;
$dark_blue: #28555b;
$light_violet: #8b5d79;
$orange: #dd8650;
$light: #ebe8e1;
$white: #ebe8e1;
$black: #232323;
$defaultButtonTextColor : #fff;
$black-button-light: #392f2c;
$black-button-dark: #1e0b11;
$whiteButtonLight: #f5f5f5;
$whiteButtonDark: #f5f5f5; // TODO: найти оптимальный цвет для прожатого состояния
@function darker($color) {
$elyColorsMap : (

View File

@ -19,7 +19,9 @@ export default class Button extends FormComponent {
PropTypes.string
]).isRequired,
block: PropTypes.bool,
color: PropTypes.oneOf(colors)
small: PropTypes.bool,
color: PropTypes.oneOf(colors),
className: PropTypes.string
};
static defaultProps = {
@ -27,7 +29,7 @@ export default class Button extends FormComponent {
};
render() {
const { color, block, small } = this.props;
const { color, block, small, className } = this.props;
const props = omit(this.props, Object.keys(Button.propTypes));
@ -37,7 +39,7 @@ export default class Button extends FormComponent {
<button className={classNames(buttons[color], {
[buttons.block]: block,
[buttons.smallButton]: small
})}
}, className)}
{...props}
>
{label}

View File

@ -8,6 +8,8 @@ export const COLOR_VIOLET = 'violet';
export const COLOR_LIGHT_VIOLET = 'lightViolet';
export const COLOR_ORANGE = 'orange';
export const COLOR_RED = 'red';
export const COLOR_BLACK = 'black';
export const COLOR_WHITE = 'white';
export const colors = [
COLOR_GREEN,
@ -16,7 +18,9 @@ export const colors = [
COLOR_VIOLET,
COLOR_LIGHT_VIOLET,
COLOR_ORANGE,
COLOR_RED
COLOR_RED,
COLOR_BLACK,
COLOR_WHITE
];
export const skins = [SKIN_DARK, SKIN_LIGHT];

View File

@ -15,7 +15,6 @@
&.is-active {
opacity: 1;
visibility: visible;
transition: 0.05s ease;
}
}

View File

@ -76,7 +76,7 @@ $popupMargin: 20px; // Отступ попапа от краёв окна
.header {
position: relative;
background: $light;
background: $white;
padding: 15px $popupPadding;
border-bottom: 1px solid #dedede;
}

View File

@ -4,7 +4,7 @@ const KEY_USER = 'user';
export default class User {
/**
* @param {object|string|undefined} data plain object or jwt token or empty to load from storage
* @param {object} [data] - plain object or jwt token or empty to load from storage
*
* @return {User}
*/
@ -18,8 +18,6 @@ export default class User {
const defaults = {
id: null,
uuid: null,
token: '',
refreshToken: '',
username: '',
email: '',
// will contain user's email or masked email
@ -27,12 +25,14 @@ export default class User {
maskedEmail: '',
avatar: '',
lang: '',
goal: null, // the goal with wich user entered site
isGuest: true,
isActive: false,
shouldAcceptRules: false, // whether user need to review updated rules
passwordChangedAt: null,
hasMojangUsernameCollision: false,
// frontend app specific attributes
isGuest: true,
goal: null, // the goal with wich user entered site
};
const user = Object.keys(defaults).reduce((user, key) => {

View File

@ -1,14 +1,16 @@
import { routeActions } from 'react-router-redux';
import captcha from 'services/captcha';
import accounts from 'services/api/accounts';
import { logoutAll } from 'components/accounts/actions';
import authentication from 'services/api/authentication';
import { setLocale } from 'components/i18n/actions';
export const UPDATE = 'USER_UPDATE';
/**
* @param {string|object} payload jwt token or user object
* @return {object} action definition
* Merge data into user's state
*
* @param {object} payload
* @return {object} - action definition
*/
export function updateUser(payload) {
return {
@ -23,23 +25,26 @@ export function changeLang(lang) {
.then((lang) => {
const {user: {isGuest, lang: oldLang}} = getState();
if (!isGuest && oldLang !== lang) {
accounts.changeLang(lang);
if (oldLang !== lang) {
!isGuest && accounts.changeLang(lang);
dispatch({
type: CHANGE_LANG,
payload: {
lang
}
});
}
// TODO: probably should be moved from here, because it is side effect
captcha.setLang(lang);
dispatch({
type: CHANGE_LANG,
payload: {
lang
}
});
});
}
export const SET = 'USER_SET';
/**
* Replace current user's state with a new one
*
* @param {User} payload
* @return {object} - action definition
*/
export function setUser(payload) {
return {
type: SET,
@ -49,22 +54,16 @@ export function setUser(payload) {
export function logout() {
return (dispatch, getState) => {
if (getState().user.token) {
authentication.logout();
}
dispatch(setUser({
lang: getState().user.lang,
isGuest: true
}));
return new Promise((resolve) => {
setTimeout(() => { // a tiny timeout to allow logout before user's token will be removed
dispatch(setUser({
lang: getState().user.lang,
isGuest: true
}));
dispatch(logoutAll());
dispatch(routeActions.push('/login'));
dispatch(routeActions.push('/login'));
resolve();
}, 0);
});
return Promise.resolve();
};
}
@ -72,7 +71,10 @@ export function fetchUserData() {
return (dispatch) =>
accounts.current()
.then((resp) => {
dispatch(updateUser(resp));
dispatch(updateUser({
isGuest: false,
...resp
}));
return dispatch(changeLang(resp.lang));
});
@ -80,31 +82,11 @@ export function fetchUserData() {
export function acceptRules() {
return (dispatch) =>
accounts.acceptRules()
.then((resp) => {
accounts.acceptRules().then((resp) => {
dispatch(updateUser({
shouldAcceptRules: false
}));
return resp;
})
;
}
export function authenticate(token, refreshToken) { // TODO: this action, probably, belongs to components/auth
return (dispatch, getState) => {
refreshToken = refreshToken || getState().user.refreshToken;
dispatch(updateUser({
token,
refreshToken
}));
return dispatch(fetchUserData()).then((resp) => {
dispatch(updateUser({
isGuest: false
}));
return resp;
});
};
}

View File

@ -1,4 +1,5 @@
import { authenticate, changeLang } from 'components/user/actions';
import { changeLang } from 'components/user/actions';
import { authenticate } from 'components/accounts/actions';
import request from 'services/request';
import bearerHeaderMiddleware from './middlewares/bearerHeaderMiddleware';
@ -22,11 +23,11 @@ export function factory(store) {
request.addMiddleware(bearerHeaderMiddleware(store));
promise = new Promise((resolve, reject) => {
const {user} = store.getState();
const {user, accounts} = store.getState();
if (user.token) {
if (accounts.active || user.token) {
// authorizing user if it is possible
return store.dispatch(authenticate(user.token)).then(resolve, reject);
return store.dispatch(authenticate(accounts.active || user)).then(resolve, reject);
}
// auto-detect guests language

View File

@ -8,14 +8,20 @@
*/
export default function bearerHeaderMiddleware({getState}) {
return {
before(data) {
const {token} = getState().user;
before(req) {
const {user, accounts} = getState();
if (token) {
data.options.headers.Authorization = `Bearer ${token}`;
let {token} = accounts.active ? accounts.active : user;
if (req.options.token) {
token = req.options.token;
}
return data;
if (token) {
req.options.headers.Authorization = `Bearer ${token}`;
}
return req;
}
};
}

View File

@ -1,5 +1,6 @@
import authentication from 'services/api/authentication';
import {updateUser, logout} from '../actions';
import { updateToken } from 'components/accounts/actions';
import { logout } from '../actions';
/**
* Ensures, that all user's requests have fresh access token
@ -12,53 +13,52 @@ import {updateUser, logout} from '../actions';
*/
export default function refreshTokenMiddleware({dispatch, getState}) {
return {
before(data) {
const {refreshToken, token} = getState().user;
const isRefreshTokenRequest = data.url.includes('refresh-token');
before(req) {
const {user, accounts} = getState();
if (!token || isRefreshTokenRequest) {
return data;
let refreshToken;
let token;
const isRefreshTokenRequest = req.url.includes('refresh-token');
if (accounts.active) {
token = accounts.active.token;
refreshToken = accounts.active.refreshToken;
} else { // #legacy token
token = user.token;
refreshToken = user.refreshToken;
}
if (!token || req.options.token || isRefreshTokenRequest) {
return req;
}
try {
const SAFETY_FACTOR = 60; // ask new token earlier to overcome time dissynchronization problem
const SAFETY_FACTOR = 300; // ask new token earlier to overcome time dissynchronization problem
const jwt = getJWTPayload(token);
if (jwt.exp - SAFETY_FACTOR < Date.now() / 1000) {
return requestAccessToken(refreshToken, dispatch).then(() => data);
return requestAccessToken(refreshToken, dispatch).then(() => req);
}
} catch (err) {
dispatch(logout());
// console.error('Bad token', err); // TODO: it would be cool to log such things to backend
return dispatch(logout()).then(() => req);
}
return data;
return Promise.resolve(req);
},
catch(resp, restart) {
/*
{
"name": "Unauthorized",
"message": "You are requesting with an invalid credential.",
"code": 0,
"status": 401,
"type": "yii\\web\\UnauthorizedHttpException"
}
{
"name": "Unauthorized",
"message": "Token expired",
"code": 0,
"status": 401,
"type": "yii\\web\\UnauthorizedHttpException"
}
*/
if (resp && resp.status === 401) {
const {refreshToken} = getState().user;
catch(resp, req, restart) {
if (resp && resp.status === 401 && !req.options.token) {
const {user, accounts} = getState();
const {refreshToken} = accounts.active ? accounts.active : user;
if (resp.message === 'Token expired' && refreshToken) {
// request token and retry
return requestAccessToken(refreshToken, dispatch).then(restart);
}
dispatch(logout());
return dispatch(logout()).then(() => Promise.reject(resp));
}
return Promise.reject(resp);
@ -75,9 +75,7 @@ function requestAccessToken(refreshToken, dispatch) {
}
return promise
.then(({token}) => dispatch(updateUser({
token
})))
.then(({token}) => dispatch(updateToken(token)))
.catch(() => dispatch(logout()));
}

View File

@ -1,51 +0,0 @@
import React, { Component, PropTypes } from 'react';
import classNames from 'classnames';
import { Link } from 'react-router';
import { intlShape } from 'react-intl';
import buttons from 'components/ui/buttons.scss';
import buttonGroups from 'components/ui/button-groups.scss';
import messages from './LoggedInPanel.intl.json';
import styles from './loggedInPanel.scss';
import { userShape } from 'components/user/User';
export default class LoggedInPanel extends Component {
static displayName = 'LoggedInPanel';
static propTypes = {
user: userShape,
onLogout: PropTypes.func.isRequired
};
static contextTypes = {
intl: intlShape.isRequired
};
render() {
const { user } = this.props;
return (
<div className={classNames(buttonGroups.horizontalGroup, styles.loggedInPanel)}>
<Link to="/" className={classNames(buttons.green, buttonGroups.item)}>
<span className={styles.userIcon} />
<span className={styles.userName}>{user.username}</span>
</Link>
<button
onClick={this.onLogout}
className={classNames(buttons.green, buttonGroups.item)}
title={this.context.intl.formatMessage(messages.logout)}
>
<span className={styles.logoutIcon} />
</button>
</div>
);
}
onLogout = (event) => {
event.preventDefault();
this.props.onLogout();
};
}

View File

@ -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 (
<div className={classNames(styles.loggedInPanel)}>
<div className={classNames(styles.activeAccount, {
[styles.activeAccountExpanded]: isAccountSwitcherActive
})}>
<button className={styles.activeAccountButton} onClick={this.onExpandAccountSwitcher}>
<span className={styles.userIcon} />
<span className={styles.userName}>{user.username}</span>
<span className={styles.expandIcon} />
</button>
<div className={classNames(styles.accountSwitcherContainer)}>
<AccountSwitcher skin="light" onAfterAction={this.toggleAccountSwitcher} />
</div>
</div>
</div>
);
}
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);
}
}
};
}

View File

@ -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;
}

View File

@ -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} як сэрвіс",

View File

@ -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",

View File

@ -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} как сервис",

View File

@ -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.",

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 138 276">
<rect x="34" class="st0" width="69" height="276"/>
<rect y="69" class="st0" width="138" height="103"/>
</svg>

After

Width:  |  Height:  |  Size: 243 B

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<svg version="1.2" baseProfile="tiny" id="&#x421;&#x43B;&#x43E;&#x439;_1"
xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="17px" height="19px"
viewBox="0 0 17 19">
<polygon fill="#FFFFFF" points="17,11.93 10.927,11.93 10.927,18 6.073,18 6.073,11.93 0,11.93 0,7.064 6.073,7.064 6.073,1
10.927,1 10.927,7.064 17,7.064 "/>
</svg>

After

Width:  |  Height:  |  Size: 387 B

View File

@ -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';

View File

@ -11,7 +11,7 @@ body,
body {
font-family: $font-family-base;
background: $light;
background: $white;
color: #444;
font-size: 16px;
}

View File

@ -31,12 +31,11 @@ function RootPage(props) {
})}>
<div className={styles.header}>
<div className={styles.headerContent}>
<Link to="/" className={styles.logo}>
<Link to="/" className={styles.logo} onClick={props.resetOAuth}>
<Message {...messages.siteName} />
</Link>
<div className={styles.userbar}>
<Userbar {...props}
onLogout={props.logout}
guestAction={isRegisterPage ? 'login' : 'register'}
/>
</div>
@ -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);

View File

@ -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;

View File

@ -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

View File

@ -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) {
<Route path="/activation(/:key)" components={new Activation()} {...startAuthFlow} />
<Route path="/resend-activation" components={new ResendActivation()} {...startAuthFlow} />
<Route path="/oauth/permissions" components={new Permissions()} {...startAuthFlow} />
<Route path="/oauth/choose-account" components={new ChooseAccount()} {...startAuthFlow} />
<Route path="/oauth/finish" component={Finish} {...startAuthFlow} />
<Route path="/accept-rules" components={new AcceptRules()} {...startAuthFlow} />
<Route path="/forgot-password" components={new ForgotPassword()} {...startAuthFlow} />

View File

@ -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<User>}
*/
current(options = {}) {
return request.get('/api/accounts/current', {}, {
token: options.token
});
},
changePassword({

View File

@ -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;

View File

@ -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
};
}

View File

@ -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;
}

View File

@ -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');
}
}

View File

@ -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;
}

View File

@ -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());
}
}

View File

@ -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()));
}

View File

@ -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());
}
}

View File

@ -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);

9
src/services/loader.js Normal file
View File

@ -0,0 +1,9 @@
export default {
show() {
document.getElementById('loader').classList.add('is-active');
},
hide() {
document.getElementById('loader').classList.remove('is-active');
}
};

View File

@ -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)))
;
}

View File

@ -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);

View File

@ -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()
]);
});
});
});

View File

@ -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]
});
});
});
});

View File

@ -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')]
]);
})
);
});
});
});

View File

@ -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
});
});
});
});

View File

@ -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()
]);
})
);
}
});
});

View File

@ -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}`
});
}
});

View File

@ -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');
})
);
});
});
});

View File

@ -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}
]);
});
});
});

View File

@ -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: []
}
}
});

View File

@ -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,

View File

@ -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);
});
});
});

View File

@ -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
}
}

View File

@ -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'});
});
});

View File

@ -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() {}});

View File

@ -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);

View File

@ -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) {