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", "up": "npm update",
"test": "karma start ./karma.conf.js", "test": "karma start ./karma.conf.js",
"lint": "eslint ./src", "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" "build": "rm -rf dist/ && webpack --progress --colors -p"
}, },
"dependencies": { "dependencies": {
@ -33,6 +33,7 @@
"react-router": "^2.0.0", "react-router": "^2.0.0",
"react-router-redux": "^3.0.0", "react-router-redux": "^3.0.0",
"redux": "^3.0.4", "redux": "^3.0.4",
"redux-localstorage": "^0.4.1",
"redux-thunk": "^2.0.0", "redux-thunk": "^2.0.0",
"webfontloader": "^1.6.26", "webfontloader": "^1.6.26",
"whatwg-fetch": "^1.0.0" "whatwg-fetch": "^1.0.0"
@ -50,6 +51,7 @@
"babel-preset-stage-0": "^6.3.13", "babel-preset-stage-0": "^6.3.13",
"babel-runtime": "^6.0.0", "babel-runtime": "^6.0.0",
"bundle-loader": "^0.5.4", "bundle-loader": "^0.5.4",
"circular-dependency-plugin": "^2.0.0",
"css-loader": "^0.23.0", "css-loader": "^0.23.0",
"enzyme": "^2.2.0", "enzyme": "^2.2.0",
"eslint": "^3.1.1", "eslint": "^3.1.1",

View File

@ -5,8 +5,8 @@ import {sync as mkdirpSync} from 'mkdirp';
import chalk from 'chalk'; import chalk from 'chalk';
import prompt from 'prompt'; import prompt from 'prompt';
const MESSAGES_PATTERN = '../dist/messages/**/*.json'; const MESSAGES_PATTERN = `${__dirname}/../dist/messages/**/*.json`;
const LANG_DIR = '../src/i18n'; const LANG_DIR = `${__dirname}/../src/i18n`;
const DEFAULT_LOCALE = 'en'; const DEFAULT_LOCALE = 'en';
const SUPPORTED_LANGS = [DEFAULT_LOCALE].concat('ru', 'be', 'uk'); 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'], ['login', 'password', 'forgotPassword', 'recoverPassword'],
['register', 'activation', 'resendActivation'], ['register', 'activation', 'resendActivation'],
['acceptRules'], ['acceptRules'],
['permissions'] ['chooseAccount', 'permissions']
]; ];
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
@ -64,10 +64,7 @@ class PanelTransition extends Component {
payload: PropTypes.object payload: PropTypes.object
})]), })]),
isLoading: PropTypes.bool, isLoading: PropTypes.bool,
login: PropTypes.shape({ login: PropTypes.string
login: PropTypes.string,
password: PropTypes.string
})
}).isRequired, }).isRequired,
user: userShape.isRequired, user: userShape.isRequired,
setErrors: PropTypes.func.isRequired, setErrors: PropTypes.func.isRequired,
@ -89,12 +86,12 @@ class PanelTransition extends Component {
type: PropTypes.string, type: PropTypes.string,
payload: PropTypes.object payload: PropTypes.object
})]), })]),
login: PropTypes.shape({ login: PropTypes.string
login: PropTypes.string,
password: PropTypes.string
})
}), }),
user: userShape, user: userShape,
accounts: PropTypes.shape({
available: PropTypes.array
}),
requestRedraw: PropTypes.func, requestRedraw: PropTypes.func,
clearErrors: PropTypes.func, clearErrors: PropTypes.func,
resolve: PropTypes.func, resolve: PropTypes.func,
@ -314,7 +311,12 @@ class PanelTransition extends Component {
} }
shouldMeasureHeight() { 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}) { getHeader({key, style, data}) {
@ -446,12 +448,35 @@ class PanelTransition extends Component {
} }
} }
export default connect((state) => ({ export default connect((state) => {
user: state.user, const {login} = state.auth;
auth: state.auth, let user = {
resolve: authFlow.resolve.bind(authFlow), ...state.user
reject: authFlow.reject.bind(authFlow) };
}), {
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, clearErrors: actions.clearErrors,
setErrors: actions.setErrors setErrors: actions.setErrors
})(PanelTransition); })(PanelTransition);

View File

@ -2,12 +2,12 @@
To add new panel you need to: 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]` * create panel component at `components/auth/[panelId]`
* add new context in `components/auth/PanelTransition` * add new context in `components/auth/PanelTransition`
* connect component to `routes` * 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 * whatever else you need
Commit id with example: f4d315c Commit id with example: f4d315c

View File

@ -1,6 +1,7 @@
import { routeActions } from 'react-router-redux'; 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 authentication from 'services/api/authentication';
import oauth from 'services/api/oauth'; import oauth from 'services/api/oauth';
import signup from 'services/api/signup'; import signup from 'services/api/signup';
@ -19,24 +20,13 @@ export function login({login = '', password = '', rememberMe = false}) {
.catch((resp) => { .catch((resp) => {
if (resp.errors) { if (resp.errors) {
if (resp.errors.password === PASSWORD_REQUIRED) { if (resp.errors.password === PASSWORD_REQUIRED) {
let username = ''; return dispatch(setLogin(login));
let email = '';
if (/[@.]/.test(login)) {
email = login;
} else {
username = login;
}
return dispatch(updateUser({
username,
email
}));
} else if (resp.errors.login === ACTIVATION_REQUIRED) { } else if (resp.errors.login === ACTIVATION_REQUIRED) {
return dispatch(needActivation()); return dispatch(needActivation());
} else if (resp.errors.login === LOGIN_REQUIRED && password) { } else if (resp.errors.login === LOGIN_REQUIRED && password) {
// TODO: log this case to backend
// return to the first step // 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) { export function setErrors(errors) {
return { return {
type: ERROR, type: ERROR,
@ -138,9 +144,8 @@ export function clearErrors() {
return setErrors(null); return setErrors(null);
} }
export function logout() { export { logout, updateUser } from 'components/user/actions';
return logoutUser(); export { authenticate } from 'components/accounts/actions';
}
/** /**
* @param {object} oauthData * @param {object} oauthData
@ -149,6 +154,13 @@ export function logout() {
* @param {string} oauthData.responseType * @param {string} oauthData.responseType
* @param {string} oauthData.description * @param {string} oauthData.description
* @param {string} oauthData.scope * @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 * @param {string} oauthData.state
* *
* @return {Promise} * @return {Promise}
@ -159,8 +171,17 @@ export function oAuthValidate(oauthData) {
return wrapInLoader((dispatch) => return wrapInLoader((dispatch) =>
oauth.validate(oauthData) oauth.validate(oauthData)
.then((resp) => { .then((resp) => {
let prompt = (oauthData.prompt || 'none').split(',').map((item) => item.trim);
if (prompt.includes('none')) {
prompt = ['none'];
}
dispatch(setClient(resp.client)); dispatch(setClient(resp.client));
dispatch(setOAuthRequest(resp.oAuth)); dispatch(setOAuthRequest({
...resp.oAuth,
prompt: oauthData.prompt || 'none',
loginHint: oauthData.loginHint
}));
dispatch(setScopes(resp.session.scopes)); dispatch(setScopes(resp.session.scopes));
localStorage.setItem('oauthData', JSON.stringify({ // @see services/authFlow/AuthFlow localStorage.setItem('oauthData', JSON.stringify({ // @see services/authFlow/AuthFlow
timestamp: Date.now(), 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 const SET_OAUTH = 'set_oauth';
export function setOAuthRequest(oauth) { export function setOAuthRequest(oauth) {
return { return {
@ -235,6 +263,8 @@ export function setOAuthRequest(oauth) {
redirectUrl: oauth.redirect_uri, redirectUrl: oauth.redirect_uri,
responseType: oauth.response_type, responseType: oauth.response_type,
scope: oauth.scope, scope: oauth.scope,
prompt: oauth.prompt,
loginHint: oauth.loginHint,
state: oauth.state state: oauth.state
} }
}; };
@ -305,7 +335,14 @@ function needActivation() {
} }
function authHandler(dispatch) { 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) { 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 { 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({ export default combineReducers({
login,
error, error,
isLoading, isLoading,
isSwitcherEnabled,
client, client,
oauth, oauth,
scopes scopes
@ -19,6 +31,7 @@ function error(
if (!error) { if (!error) {
throw new Error('Expected payload with error'); throw new Error('Expected payload with error');
} }
return payload; return payload;
default: 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( function isLoading(
state = false, state = false,
@ -68,6 +114,8 @@ function oauth(
redirectUrl: payload.redirectUrl, redirectUrl: payload.redirectUrl,
responseType: payload.responseType, responseType: payload.responseType,
scope: payload.scope, scope: payload.scope,
prompt: payload.prompt,
loginHint: payload.loginHint,
state: payload.state state: payload.state
}; };

View File

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

View File

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

View File

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

View File

@ -3,6 +3,9 @@
} }
.item { .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; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@ const KEY_USER = 'user';
export default class 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} * @return {User}
*/ */
@ -18,8 +18,6 @@ export default class User {
const defaults = { const defaults = {
id: null, id: null,
uuid: null, uuid: null,
token: '',
refreshToken: '',
username: '', username: '',
email: '', email: '',
// will contain user's email or masked email // will contain user's email or masked email
@ -27,12 +25,14 @@ export default class User {
maskedEmail: '', maskedEmail: '',
avatar: '', avatar: '',
lang: '', lang: '',
goal: null, // the goal with wich user entered site
isGuest: true,
isActive: false, isActive: false,
shouldAcceptRules: false, // whether user need to review updated rules shouldAcceptRules: false, // whether user need to review updated rules
passwordChangedAt: null, passwordChangedAt: null,
hasMojangUsernameCollision: false, 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) => { const user = Object.keys(defaults).reduce((user, key) => {

View File

@ -1,14 +1,16 @@
import { routeActions } from 'react-router-redux'; import { routeActions } from 'react-router-redux';
import captcha from 'services/captcha';
import accounts from 'services/api/accounts'; import accounts from 'services/api/accounts';
import { logoutAll } from 'components/accounts/actions';
import authentication from 'services/api/authentication'; import authentication from 'services/api/authentication';
import { setLocale } from 'components/i18n/actions'; import { setLocale } from 'components/i18n/actions';
export const UPDATE = 'USER_UPDATE'; export const UPDATE = 'USER_UPDATE';
/** /**
* @param {string|object} payload jwt token or user object * Merge data into user's state
* @return {object} action definition *
* @param {object} payload
* @return {object} - action definition
*/ */
export function updateUser(payload) { export function updateUser(payload) {
return { return {
@ -23,23 +25,26 @@ export function changeLang(lang) {
.then((lang) => { .then((lang) => {
const {user: {isGuest, lang: oldLang}} = getState(); const {user: {isGuest, lang: oldLang}} = getState();
if (!isGuest && oldLang !== lang) { if (oldLang !== lang) {
accounts.changeLang(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'; 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) { export function setUser(payload) {
return { return {
type: SET, type: SET,
@ -49,22 +54,16 @@ export function setUser(payload) {
export function logout() { export function logout() {
return (dispatch, getState) => { return (dispatch, getState) => {
if (getState().user.token) { dispatch(setUser({
authentication.logout(); lang: getState().user.lang,
} isGuest: true
}));
return new Promise((resolve) => { dispatch(logoutAll());
setTimeout(() => { // a tiny timeout to allow logout before user's token will be removed
dispatch(setUser({
lang: getState().user.lang,
isGuest: true
}));
dispatch(routeActions.push('/login')); dispatch(routeActions.push('/login'));
resolve(); return Promise.resolve();
}, 0);
});
}; };
} }
@ -72,7 +71,10 @@ export function fetchUserData() {
return (dispatch) => return (dispatch) =>
accounts.current() accounts.current()
.then((resp) => { .then((resp) => {
dispatch(updateUser(resp)); dispatch(updateUser({
isGuest: false,
...resp
}));
return dispatch(changeLang(resp.lang)); return dispatch(changeLang(resp.lang));
}); });
@ -80,31 +82,11 @@ export function fetchUserData() {
export function acceptRules() { export function acceptRules() {
return (dispatch) => return (dispatch) =>
accounts.acceptRules() accounts.acceptRules().then((resp) => {
.then((resp) => {
dispatch(updateUser({ dispatch(updateUser({
shouldAcceptRules: false 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; 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 request from 'services/request';
import bearerHeaderMiddleware from './middlewares/bearerHeaderMiddleware'; import bearerHeaderMiddleware from './middlewares/bearerHeaderMiddleware';
@ -22,11 +23,11 @@ export function factory(store) {
request.addMiddleware(bearerHeaderMiddleware(store)); request.addMiddleware(bearerHeaderMiddleware(store));
promise = new Promise((resolve, reject) => { 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 // 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 // auto-detect guests language

View File

@ -8,14 +8,20 @@
*/ */
export default function bearerHeaderMiddleware({getState}) { export default function bearerHeaderMiddleware({getState}) {
return { return {
before(data) { before(req) {
const {token} = getState().user; const {user, accounts} = getState();
if (token) { let {token} = accounts.active ? accounts.active : user;
data.options.headers.Authorization = `Bearer ${token}`;
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 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 * 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}) { export default function refreshTokenMiddleware({dispatch, getState}) {
return { return {
before(data) { before(req) {
const {refreshToken, token} = getState().user; const {user, accounts} = getState();
const isRefreshTokenRequest = data.url.includes('refresh-token');
if (!token || isRefreshTokenRequest) { let refreshToken;
return data; 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 { 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); const jwt = getJWTPayload(token);
if (jwt.exp - SAFETY_FACTOR < Date.now() / 1000) { if (jwt.exp - SAFETY_FACTOR < Date.now() / 1000) {
return requestAccessToken(refreshToken, dispatch).then(() => data); return requestAccessToken(refreshToken, dispatch).then(() => req);
} }
} catch (err) { } 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) { catch(resp, req, restart) {
/* if (resp && resp.status === 401 && !req.options.token) {
{ const {user, accounts} = getState();
"name": "Unauthorized", const {refreshToken} = accounts.active ? accounts.active : user;
"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;
if (resp.message === 'Token expired' && refreshToken) { if (resp.message === 'Token expired' && refreshToken) {
// request token and retry // request token and retry
return requestAccessToken(refreshToken, dispatch).then(restart); return requestAccessToken(refreshToken, dispatch).then(restart);
} }
dispatch(logout()); return dispatch(logout()).then(() => Promise.reject(resp));
} }
return Promise.reject(resp); return Promise.reject(resp);
@ -75,9 +75,7 @@ function requestAccessToken(refreshToken, dispatch) {
} }
return promise return promise
.then(({token}) => dispatch(updateUser({ .then(({token}) => dispatch(updateToken(token)))
token
})))
.catch(() => dispatch(logout())); .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 { .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 { .userIcon {
@ -11,12 +39,24 @@
padding-right: 5px; padding-right: 5px;
} }
.expandIcon {
composes: caret from 'components/ui/icons.scss';
margin-left: 4px;
font-size: 6px;
color: #CCC;
transition: .2s;
}
.userName { .userName {
} }
.logoutIcon { .accountSwitcherContainer {
composes: exit from 'components/ui/icons.scss'; 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.accept": "Прыняць",
"components.auth.acceptRules.declineAndLogout": "Адмовіцца і выйсці", "components.auth.acceptRules.declineAndLogout": "Адмовіцца і выйсці",
"components.auth.acceptRules.description1": "Мы аднавілі {link}.", "components.auth.acceptRules.description1": "Мы аднавілі {link}.",
@ -15,6 +18,10 @@
"components.auth.appInfo.documentation": "дакументацыю", "components.auth.appInfo.documentation": "дакументацыю",
"components.auth.appInfo.goToAuth": "Да аўтарызацыі", "components.auth.appInfo.goToAuth": "Да аўтарызацыі",
"components.auth.appInfo.useItYourself": "Наведайце нашу {link}, каб даведацца, як выкарыстоўваць гэты сэрвіс ў сваіх праектах.", "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.authForAppFailed": "Аўтарызацыя для {appName} не атрымалася",
"components.auth.finish.authForAppSuccessful": "Аўтарызацыя для {appName} паспяхова выканана", "components.auth.finish.authForAppSuccessful": "Аўтарызацыя для {appName} паспяхова выканана",
"components.auth.finish.copy": "Скапіяваць", "components.auth.finish.copy": "Скапіяваць",
@ -126,7 +133,6 @@
"components.profile.projectRules": "правілах праекта", "components.profile.projectRules": "правілах праекта",
"components.profile.twoFactorAuth": "Двухфактарная аўтэнтыфікацыя", "components.profile.twoFactorAuth": "Двухфактарная аўтэнтыфікацыя",
"components.userbar.login": "Уваход", "components.userbar.login": "Уваход",
"components.userbar.logout": "Выхад",
"components.userbar.register": "Рэгістрацыя", "components.userbar.register": "Рэгістрацыя",
"pages.root.siteName": "Ёly.by", "pages.root.siteName": "Ёly.by",
"pages.rules.elyAccountsAsService": "{name} як сэрвіс", "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.accept": "Accept",
"components.auth.acceptRules.declineAndLogout": "Decline and logout", "components.auth.acceptRules.declineAndLogout": "Decline and logout",
"components.auth.acceptRules.description1": "We have updated our {link}.", "components.auth.acceptRules.description1": "We have updated our {link}.",
@ -15,6 +18,10 @@
"components.auth.appInfo.documentation": "documentation", "components.auth.appInfo.documentation": "documentation",
"components.auth.appInfo.goToAuth": "Go to auth", "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.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.authForAppFailed": "Authorization for {appName} was failed",
"components.auth.finish.authForAppSuccessful": "Authorization for {appName} was successfully completed", "components.auth.finish.authForAppSuccessful": "Authorization for {appName} was successfully completed",
"components.auth.finish.copy": "Copy", "components.auth.finish.copy": "Copy",
@ -126,7 +133,6 @@
"components.profile.projectRules": "project rules", "components.profile.projectRules": "project rules",
"components.profile.twoFactorAuth": "Two factor auth", "components.profile.twoFactorAuth": "Two factor auth",
"components.userbar.login": "Sign in", "components.userbar.login": "Sign in",
"components.userbar.logout": "Logout",
"components.userbar.register": "Join", "components.userbar.register": "Join",
"pages.root.siteName": "Ely.by", "pages.root.siteName": "Ely.by",
"pages.rules.elyAccountsAsService": "{name} as service", "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.accept": "Принять",
"components.auth.acceptRules.declineAndLogout": "Отказаться и выйти", "components.auth.acceptRules.declineAndLogout": "Отказаться и выйти",
"components.auth.acceptRules.description1": "Мы обновили {link}.", "components.auth.acceptRules.description1": "Мы обновили {link}.",
@ -15,6 +18,10 @@
"components.auth.appInfo.documentation": "документацию", "components.auth.appInfo.documentation": "документацию",
"components.auth.appInfo.goToAuth": "К авторизации", "components.auth.appInfo.goToAuth": "К авторизации",
"components.auth.appInfo.useItYourself": "Посетите нашу {link}, чтобы узнать, как использовать этот сервис в своих проектах.", "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.authForAppFailed": "Авторизация для {appName} не удалась",
"components.auth.finish.authForAppSuccessful": "Авторизация для {appName} успешно выполнена", "components.auth.finish.authForAppSuccessful": "Авторизация для {appName} успешно выполнена",
"components.auth.finish.copy": "Скопировать", "components.auth.finish.copy": "Скопировать",
@ -126,7 +133,6 @@
"components.profile.projectRules": "правилами проекта", "components.profile.projectRules": "правилами проекта",
"components.profile.twoFactorAuth": "Двухфакторная аутентификация", "components.profile.twoFactorAuth": "Двухфакторная аутентификация",
"components.userbar.login": "Вход", "components.userbar.login": "Вход",
"components.userbar.logout": "Выход",
"components.userbar.register": "Регистрация", "components.userbar.register": "Регистрация",
"pages.root.siteName": "Ely.by", "pages.root.siteName": "Ely.by",
"pages.rules.elyAccountsAsService": "{name} как сервис", "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.accept": "Прийняти",
"components.auth.acceptRules.declineAndLogout": "Відмовитись і вийти", "components.auth.acceptRules.declineAndLogout": "Відмовитись і вийти",
"components.auth.acceptRules.description1": "Ми оновили наші {link}.", "components.auth.acceptRules.description1": "Ми оновили наші {link}.",
@ -15,6 +18,10 @@
"components.auth.appInfo.documentation": "документацію", "components.auth.appInfo.documentation": "документацію",
"components.auth.appInfo.goToAuth": "До авторизації", "components.auth.appInfo.goToAuth": "До авторизації",
"components.auth.appInfo.useItYourself": "Відвідайте нашу {link}, щоб дізнатися, як використовувати цей сервіс в своїх проектах.", "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.authForAppFailed": "Авторизація для {appName} не вдалася",
"components.auth.finish.authForAppSuccessful": "Авторизація для {appName} успішно виконана", "components.auth.finish.authForAppSuccessful": "Авторизація для {appName} успішно виконана",
"components.auth.finish.copy": "Скопіювати", "components.auth.finish.copy": "Скопіювати",
@ -126,7 +133,6 @@
"components.profile.projectRules": "правилами проекта", "components.profile.projectRules": "правилами проекта",
"components.profile.twoFactorAuth": "Двофакторна аутентифікація", "components.profile.twoFactorAuth": "Двофакторна аутентифікація",
"components.userbar.login": "Вхід", "components.userbar.login": "Вхід",
"components.userbar.logout": "Вихід",
"components.userbar.register": "Реєстрація", "components.userbar.register": "Реєстрація",
"pages.root.siteName": "Ely.by", "pages.root.siteName": "Ely.by",
"pages.rules.elyAccountsAsService": "{name} як сервіс", "pages.rules.elyAccountsAsService": "{name} як сервіс",
@ -140,6 +146,7 @@
"pages.rules.emailAndNickname3": "На призначений для користувача нікнейм, який використовується у грі, не накладаються будь-які моральні обмеження.", "pages.rules.emailAndNickname3": "На призначений для користувача нікнейм, який використовується у грі, не накладаються будь-які моральні обмеження.",
"pages.rules.emailAndNickname4": "Ніки, що належать відомим особистостям, за вимогою, можуть бути звільнені у їх користь після встановленню цієї самої особистості.", "pages.rules.emailAndNickname4": "Ніки, що належать відомим особистостям, за вимогою, можуть бути звільнені у їх користь після встановленню цієї самої особистості.",
"pages.rules.emailAndNickname5": "Власник преміум-аккаунта Minecraft має право вимагати відновлення контролю над своїм ніком. У цьому випадку вам необхідно буде протягом 3-х днів змінити нік, або це буде зроблено автоматично.", "pages.rules.emailAndNickname5": "Власник преміум-аккаунта Minecraft має право вимагати відновлення контролю над своїм ніком. У цьому випадку вам необхідно буде протягом 3-х днів змінити нік, або це буде зроблено автоматично.",
"pages.rules.emailAndNickname6": "Якщо на вашому акаунті не було активності протягом останніх 3 місяців, ваш нік може будти зайнятий іншим користовичем.",
"pages.rules.emailAndNickname7": "Ми не несемо відповідальності за втрачений прогрес на ігрових серверах у результаті зміни ника, включаючи випадки зміни ника на вимогу з нашого боку.", "pages.rules.emailAndNickname7": "Ми не несемо відповідальності за втрачений прогрес на ігрових серверах у результаті зміни ника, включаючи випадки зміни ника на вимогу з нашого боку.",
"pages.rules.mainProvision1": "Сервіс {name} призначений для організації безпечного доступу до призначених для користувача аккаунтів проекту Ely.by, його партнерів і будь-яких сторонніх проектів, які бажають використовувати один з наших сервісів.", "pages.rules.mainProvision1": "Сервіс {name} призначений для організації безпечного доступу до призначених для користувача аккаунтів проекту Ely.by, його партнерів і будь-яких сторонніх проектів, які бажають використовувати один з наших сервісів.",
"pages.rules.mainProvision2": "Ми (тут і надалі) — команда розробників проекту Ely.by, що займаються створенням якісних сервісів для спільноти Minecraft.", "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 routesFactory from 'routes';
import storeFactory from 'storeFactory'; import storeFactory from 'storeFactory';
import bsodFactory from 'components/ui/bsod/factory'; import bsodFactory from 'components/ui/bsod/factory';
import loader from 'services/loader';
const store = storeFactory(); const store = storeFactory();
@ -52,7 +53,7 @@ Promise.all([
function stopLoading() { function stopLoading() {
document.getElementById('loader').classList.remove('is-active'); loader.hide();
} }
import scrollTo from 'components/ui/scrollTo'; import scrollTo from 'components/ui/scrollTo';
@ -89,7 +90,10 @@ function restoreScroll() {
/* global process: false */ /* global process: false */
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
// some shortcuts for testing on localhost // 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.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'; 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 { body {
font-family: $font-family-base; font-family: $font-family-base;
background: $light; background: $white;
color: #444; color: #444;
font-size: 16px; font-size: 16px;
} }

View File

@ -31,12 +31,11 @@ function RootPage(props) {
})}> })}>
<div className={styles.header}> <div className={styles.header}>
<div className={styles.headerContent}> <div className={styles.headerContent}>
<Link to="/" className={styles.logo}> <Link to="/" className={styles.logo} onClick={props.resetOAuth}>
<Message {...messages.siteName} /> <Message {...messages.siteName} />
</Link> </Link>
<div className={styles.userbar}> <div className={styles.userbar}>
<Userbar {...props} <Userbar {...props}
onLogout={props.logout}
guestAction={isRegisterPage ? 'login' : 'register'} guestAction={isRegisterPage ? 'login' : 'register'}
/> />
</div> </div>
@ -58,16 +57,16 @@ RootPage.propTypes = {
pathname: PropTypes.string pathname: PropTypes.string
}).isRequired, }).isRequired,
children: PropTypes.element, children: PropTypes.element,
logout: PropTypes.func.isRequired, resetOAuth: PropTypes.func.isRequired,
isPopupActive: PropTypes.bool.isRequired isPopupActive: PropTypes.bool.isRequired
}; };
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { logout } from 'components/user/actions'; import { resetOAuth } from 'components/auth/actions';
export default connect((state) => ({ export default connect((state) => ({
user: state.user, user: state.user,
isPopupActive: state.popup.popups.length > 0 isPopupActive: state.popup.popups.length > 0
}), { }), {
logout resetOAuth
})(RootPage); })(RootPage);

View File

@ -74,7 +74,7 @@
left: -40px; left: -40px;
width: calc(100% + 60px); width: calc(100% + 60px);
height: calc(100% + 20px); height: calc(100% + 20px);
background: $light; background: $white;
border-left: $border; border-left: $border;
border-right: $border; border-right: $border;
box-sizing: border-box; box-sizing: border-box;

View File

@ -4,6 +4,7 @@ import { routeReducer } from 'react-router-redux';
import auth from 'components/auth/reducer'; import auth from 'components/auth/reducer';
import user from 'components/user/reducer'; import user from 'components/user/reducer';
import accounts from 'components/accounts/reducer';
import i18n from 'components/i18n/reducer'; import i18n from 'components/i18n/reducer';
import popup from 'components/ui/popup/reducer'; import popup from 'components/ui/popup/reducer';
import bsod from 'components/ui/bsod/reducer'; import bsod from 'components/ui/bsod/reducer';
@ -12,6 +13,7 @@ export default combineReducers({
bsod, bsod,
auth, auth,
user, user,
accounts,
i18n, i18n,
popup, popup,
routing: routeReducer routing: routeReducer

View File

@ -17,6 +17,7 @@ import OAuthInit from 'components/auth/OAuthInit';
import Register from 'components/auth/register/Register'; import Register from 'components/auth/register/Register';
import Login from 'components/auth/login/Login'; import Login from 'components/auth/login/Login';
import Permissions from 'components/auth/permissions/Permissions'; import Permissions from 'components/auth/permissions/Permissions';
import ChooseAccount from 'components/auth/chooseAccount/ChooseAccount';
import Activation from 'components/auth/activation/Activation'; import Activation from 'components/auth/activation/Activation';
import ResendActivation from 'components/auth/resendActivation/ResendActivation'; import ResendActivation from 'components/auth/resendActivation/ResendActivation';
import Password from 'components/auth/password/Password'; 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="/activation(/:key)" components={new Activation()} {...startAuthFlow} />
<Route path="/resend-activation" components={new ResendActivation()} {...startAuthFlow} /> <Route path="/resend-activation" components={new ResendActivation()} {...startAuthFlow} />
<Route path="/oauth/permissions" components={new Permissions()} {...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="/oauth/finish" component={Finish} {...startAuthFlow} />
<Route path="/accept-rules" components={new AcceptRules()} {...startAuthFlow} /> <Route path="/accept-rules" components={new AcceptRules()} {...startAuthFlow} />
<Route path="/forgot-password" components={new ForgotPassword()} {...startAuthFlow} /> <Route path="/forgot-password" components={new ForgotPassword()} {...startAuthFlow} />

View File

@ -1,8 +1,17 @@
import request from 'services/request'; import request from 'services/request';
export default { 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({ changePassword({

View File

@ -1,6 +1,7 @@
import request from 'services/request'; import request from 'services/request';
import accounts from 'services/api/accounts';
export default { const authentication = {
login({ login({
login = '', login = '',
password = '', 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({ 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 * 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, response_type: oauthData.responseType,
description: oauthData.description, description: oauthData.description,
scope: oauthData.scope, scope: oauthData.scope,
prompt: oauthData.prompt,
login_hint: oauthData.loginHint,
state: oauthData.state state: oauthData.state
}; };
} }

View File

@ -158,6 +158,7 @@ export default class AuthFlow {
case '/accept-rules': case '/accept-rules':
case '/oauth/permissions': case '/oauth/permissions':
case '/oauth/finish': case '/oauth/finish':
case '/oauth/choose-account':
this.setState(new LoginState()); this.setState(new LoginState());
break; break;
@ -191,8 +192,8 @@ export default class AuthFlow {
* @return {bool} - whether oauth state is being restored * @return {bool} - whether oauth state is being restored
*/ */
restoreOAuthState() { restoreOAuthState() {
if (this.getRequest().path.indexOf('/register') === 0) { if (/^\/(register|oauth2)/.test(this.getRequest().path)) {
// allow register // allow register or the new oauth requests
return; 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 AbstractState from './AbstractState';
import LoginState from './LoginState'; import LoginState from './LoginState';
import PermissionsState from './PermissionsState'; import PermissionsState from './PermissionsState';
import ChooseAccountState from './ChooseAccountState';
import ActivationState from './ActivationState'; import ActivationState from './ActivationState';
import AcceptRulesState from './AcceptRulesState'; import AcceptRulesState from './AcceptRulesState';
import FinishState from './FinishState'; import FinishState from './FinishState';
const PROMPT_ACCOUNT_CHOOSE = 'select_account';
const PROMPT_PERMISSIONS = 'consent';
export default class CompleteState extends AbstractState { export default class CompleteState extends AbstractState {
constructor(options = {}) { constructor(options = {}) {
super(options); super(options);
@ -13,7 +17,7 @@ export default class CompleteState extends AbstractState {
} }
enter(context) { enter(context) {
const {auth = {}, user} = context.getState(); const {auth = {}, user, accounts} = context.getState();
if (user.isGuest) { if (user.isGuest) {
context.setState(new LoginState()); context.setState(new LoginState());
@ -22,13 +26,41 @@ export default class CompleteState extends AbstractState {
} else if (user.shouldAcceptRules) { } else if (user.shouldAcceptRules) {
context.setState(new AcceptRulesState()); context.setState(new AcceptRulesState());
} else if (auth.oauth && auth.oauth.clientId) { } 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()); context.setState(new FinishState());
} else { } else {
const data = {}; const data = {};
if (typeof this.isPermissionsAccepted !== 'undefined') { if (typeof this.isPermissionsAccepted !== 'undefined') {
data.accept = this.isPermissionsAccepted; data.accept = this.isPermissionsAccepted;
} else if (auth.oauth.acceptRequired) { } else if (auth.oauth.acceptRequired || auth.oauth.prompt.includes(PROMPT_PERMISSIONS)) {
context.setState(new PermissionsState()); context.setState(new PermissionsState());
return; return;
} }

View File

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

View File

@ -11,6 +11,8 @@ export default class OAuthState extends AbstractState {
responseType: query.response_type, responseType: query.response_type,
description: query.description, description: query.description,
scope: query.scope, scope: query.scope,
prompt: query.prompt,
loginHint: query.login_hint,
state: query.state state: query.state
}).then(() => context.setState(new CompleteState())); }).then(() => context.setState(new CompleteState()));
} }

View File

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

View File

@ -1,11 +1,9 @@
import AuthFlow from './AuthFlow'; import AuthFlow from './AuthFlow';
import * as actions from 'components/auth/actions'; import * as actions from 'components/auth/actions';
import {updateUser} from 'components/user/actions';
const availableActions = { const availableActions = {
...actions, ...actions
updateUser
}; };
export default new AuthFlow(availableActions); 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 { export default {
/** /**
* @param {string} url * @param {string} url
* @param {object} data * @param {object} data - request data
* @param {object} options - additional options for fetch or middlewares
* *
* @return {Promise} * @return {Promise}
*/ */
post(url, data) { post(url, data, options = {}) {
return doFetch(url, { return doFetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
}, },
body: buildQuery(data) body: buildQuery(data),
...options
}); });
}, },
/** /**
* @param {string} url * @param {string} url
* @param {object} data * @param {object} data - request data
* @param {object} options - additional options for fetch or middlewares
* *
* @return {Promise} * @return {Promise}
*/ */
get(url, data) { get(url, data, options = {}) {
if (typeof data === 'object') { if (typeof data === 'object' && Object.keys(data).length) {
const separator = url.indexOf('?') === -1 ? '?' : '&'; const separator = url.indexOf('?') === -1 ? '?' : '&';
url += separator + buildQuery(data); url += separator + buildQuery(data);
} }
return doFetch(url); return doFetch(url, options);
}, },
/** /**
@ -82,8 +85,8 @@ function doFetch(url, options = {}) {
.then(checkStatus) .then(checkStatus)
.then(toJSON, rejectWithJSON) .then(toJSON, rejectWithJSON)
.then(handleResponseSuccess) .then(handleResponseSuccess)
.then((resp) => middlewareLayer.run('then', resp)) .then((resp) => middlewareLayer.run('then', resp, {url, options}))
.catch((resp) => middlewareLayer.run('catch', resp, () => doFetch(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 thunk from 'redux-thunk';
import persistState from 'redux-localstorage';
import { syncHistory } from 'react-router-redux'; import { syncHistory } from 'react-router-redux';
import { browserHistory } from 'react-router'; import { browserHistory } from 'react-router';
@ -15,14 +16,18 @@ export default function storeFactory() {
reduxRouterMiddleware, reduxRouterMiddleware,
thunk thunk
); );
const persistStateEnhancer = persistState([
'accounts',
'user'
], {key: 'redux-storage'});
/* global process: false */ /* global process: false */
let enhancer; let enhancer;
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
enhancer = compose(middlewares); enhancer = compose(middlewares, persistStateEnhancer);
} else { } else {
const DevTools = require('containers/DevTools').default; const DevTools = require('containers/DevTools').default;
enhancer = compose(middlewares, DevTools.instrument()); enhancer = compose(middlewares, persistStateEnhancer, DevTools.instrument());
} }
const store = createStore(reducers, {}, enhancer); 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, setOAuthRequest,
setScopes, setScopes,
setOAuthCode, setOAuthCode,
requirePermissionsAccept requirePermissionsAccept,
login,
setLogin
} from 'components/auth/actions'; } from 'components/auth/actions';
const oauthData = { const oauthData = {
@ -22,8 +24,8 @@ const oauthData = {
}; };
describe('components/auth/actions', () => { describe('components/auth/actions', () => {
const dispatch = sinon.stub().named('dispatch'); const dispatch = sinon.stub().named('store.dispatch');
const getState = sinon.stub().named('getState'); const getState = sinon.stub().named('store.getState');
function callThunk(fn, ...args) { function callThunk(fn, ...args) {
const thunk = fn(...args); const thunk = fn(...args);
@ -67,21 +69,25 @@ describe('components/auth/actions', () => {
request.get.returns(Promise.resolve(resp)); request.get.returns(Promise.resolve(resp));
}); });
it('should send get request to an api', () => { it('should send get request to an api', () =>
return callThunk(oAuthValidate, oauthData).then(() => { callThunk(oAuthValidate, oauthData).then(() => {
expect(request.get, 'to have a call satisfying', ['/api/oauth2/v1/validate', {}]); expect(request.get, 'to have a call satisfying', ['/api/oauth2/v1/validate', {}]);
}); })
}); );
it('should dispatch setClient, setOAuthRequest and setScopes', () => { it('should dispatch setClient, setOAuthRequest and setScopes', () =>
return callThunk(oAuthValidate, oauthData).then(() => { callThunk(oAuthValidate, oauthData).then(() => {
expectDispatchCalls([ expectDispatchCalls([
[setClient(resp.client)], [setClient(resp.client)],
[setOAuthRequest(resp.oAuth)], [setOAuthRequest({
...resp.oAuth,
prompt: 'none',
loginHint: undefined
})],
[setScopes(resp.session.scopes)] [setScopes(resp.session.scopes)]
]); ]);
}); })
}); );
}); });
describe('#oAuthComplete()', () => { describe('#oAuthComplete()', () => {
@ -100,7 +106,7 @@ describe('components/auth/actions', () => {
return callThunk(oAuthComplete).then(() => { return callThunk(oAuthComplete).then(() => {
expect(request.post, 'to have a call satisfying', [ 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 { routeActions } from 'react-router-redux';
import request from 'services/request'; import request from 'services/request';
import { reset, RESET } from 'components/accounts/actions';
import { import {
logout, logout,
@ -11,8 +12,10 @@ import {
describe('components/user/actions', () => { describe('components/user/actions', () => {
const dispatch = sinon.stub().named('dispatch'); const getState = sinon.stub().named('store.getState');
const getState = sinon.stub().named('getState'); const dispatch = sinon.spy((arg) =>
typeof arg === 'function' ? arg(dispatch, getState) : arg
).named('store.dispatch');
const callThunk = function(fn, ...args) { const callThunk = function(fn, ...args) {
const thunk = fn(...args); const thunk = fn(...args);
@ -39,11 +42,16 @@ describe('components/user/actions', () => {
}); });
describe('user with jwt', () => { describe('user with jwt', () => {
const token = 'iLoveRockNRoll';
beforeEach(() => { beforeEach(() => {
getState.returns({ getState.returns({
user: { user: {
token: 'iLoveRockNRoll',
lang: 'foo' lang: 'foo'
},
accounts: {
active: {token},
available: [{token}]
} }
}); });
}); });
@ -62,20 +70,27 @@ describe('components/user/actions', () => {
return callThunk(logout).then(() => { return callThunk(logout).then(() => {
expect(request.post, 'to have a call satisfying', [ expect(request.post, 'to have a call satisfying', [
'/api/authentication/logout' '/api/authentication/logout', {}, {}
]); ]);
}); });
}); });
testChangedToGuest(); testChangedToGuest();
testAccountsReset();
testRedirectedToLogin(); 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(() => { beforeEach(() => {
getState.returns({ getState.returns({
user: { user: {
lang: 'foo' lang: 'foo'
},
accounts: {
active: null,
available: []
} }
}); });
}); });
@ -87,6 +102,7 @@ describe('components/user/actions', () => {
); );
testChangedToGuest(); testChangedToGuest();
testAccountsReset();
testRedirectedToLogin(); 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'; import bearerHeaderMiddleware from 'components/user/middlewares/bearerHeaderMiddleware';
describe('bearerHeaderMiddleware', () => { describe('bearerHeaderMiddleware', () => {
it('should set Authorization header', () => { const emptyState = {
user: {},
accounts: {
active: null
}
};
describe('when token available', () => {
const token = 'foo'; const token = 'foo';
const middleware = bearerHeaderMiddleware({ const middleware = bearerHeaderMiddleware({
getState: () => ({ 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} user: {token}
}) })
}); });
const data = { it('should set Authorization header', () => {
options: { const data = {
headers: {} options: {
} headers: {}
}; }
};
middleware.before(data); middleware.before(data);
expect(data.options.headers, 'to satisfy', { expectBearerHeader(data, token);
Authorization: `Bearer ${token}`
}); });
}); });
it('should not set Authorization header if no token', () => { it('should not set Authorization header if no token', () => {
const middleware = bearerHeaderMiddleware({ const middleware = bearerHeaderMiddleware({
getState: () => ({ getState: () => ({
user: {} ...emptyState
}) })
}); });
@ -41,4 +87,10 @@ describe('bearerHeaderMiddleware', () => {
expect(data.options.headers.Authorization, 'to be undefined'); 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 refreshTokenMiddleware from 'components/user/middlewares/refreshTokenMiddleware';
import authentication from 'services/api/authentication'; import authentication from 'services/api/authentication';
import { updateToken } from 'components/accounts/actions';
const refreshToken = 'foo'; const refreshToken = 'foo';
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0NzA3NjE0NDMsImV4cCI6MTQ3MDc2MTQ0MywiaWF0IjoxNDcwNzYxNDQzLCJqdGkiOiJpZDEyMzQ1NiJ9.gWdnzfQQvarGpkbldUvB8qdJZSVkvdNtCbhbbl2yJW8'; const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0NzA3NjE0NDMsImV4cCI6MTQ3MDc2MTQ0MywiaWF0IjoxNDcwNzYxNDQzLCJqdGkiOiJpZDEyMzQ1NiJ9.gWdnzfQQvarGpkbldUvB8qdJZSVkvdNtCbhbbl2yJW8';
@ -16,46 +17,170 @@ describe('refreshTokenMiddleware', () => {
beforeEach(() => { beforeEach(() => {
sinon.stub(authentication, 'requestToken').named('authentication.requestToken'); sinon.stub(authentication, 'requestToken').named('authentication.requestToken');
sinon.stub(authentication, 'logout').named('authentication.logout');
getState = sinon.stub().named('store.getState'); 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}); middleware = refreshTokenMiddleware({getState, dispatch});
}); });
afterEach(() => { afterEach(() => {
authentication.requestToken.restore(); 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', () => { describe('#before', () => {
it('should request new token', () => { describe('when token expired', () => {
getState.returns({ beforeEach(() => {
user: { const account = {
token: expiredToken, token: expiredToken,
refreshToken refreshToken
} };
getState.returns({
accounts: {
active: account,
available: [account]
},
user: {}
});
}); });
const data = { it('should request new token', () => {
url: 'foo', const data = {
options: { url: 'foo',
headers: {} 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(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 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', () => { it('should not be applied if no token', () => {
getState.returns({ getState.returns({
accounts: {
active: null
},
user: {} user: {}
}); });
@ -66,75 +191,124 @@ describe('refreshTokenMiddleware', () => {
expect(authentication.requestToken, 'was not called'); 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', () => { 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({ getState.returns({
user: { accounts: {
refreshToken active: {refreshToken},
} available: [{refreshToken}]
},
user: {}
}); });
const restart = sinon.stub().named('restart'); restart = sinon.stub().named('restart');
authentication.requestToken.returns(Promise.resolve({token: validToken})); authentication.requestToken.returns(Promise.resolve({token: validToken}));
});
return middleware.catch({ it('should request new token if expired', () =>
status: 401, middleware.catch(expiredResponse, {options: {}}, restart).then(() => {
message: 'Token expired'
}, restart).then(() => {
expect(authentication.requestToken, 'to have a call satisfying', [ expect(authentication.requestToken, 'to have a call satisfying', [
refreshToken refreshToken
]); ]);
expect(restart, 'was called'); 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', () => { it('should pass the rest of failed requests through', () => {
const resp = {}; const resp = {};
const promise = middleware.catch(resp); const promise = middleware.catch(resp, {
options: {}
}, restart);
expect(promise, 'to be rejected'); return expect(promise, 'to be rejected with', resp).then(() => {
expect(restart, 'was not called');
return promise.catch((actual) => { expect(authentication.requestToken, 'was not called');
expect(actual, 'to be', resp);
}); });
}); });
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 = { state.user = {
isGuest: true isGuest: true
}; };
state.auth = {
login: null
};
}); });
it('should redirect guest / -> /login', () => { it('should redirect guest / -> /login', () => {
@ -81,7 +84,8 @@ describe('AuthFlow.functional', () => {
auth: { auth: {
oauth: { oauth: {
clientId: 123 clientId: 123,
prompt: []
} }
} }
}); });

View File

@ -267,6 +267,7 @@ describe('AuthFlow', () => {
'/password': LoginState, '/password': LoginState,
'/accept-rules': LoginState, '/accept-rules': LoginState,
'/oauth/permissions': LoginState, '/oauth/permissions': LoginState,
'/oauth/choose-account': LoginState,
'/oauth/finish': LoginState, '/oauth/finish': LoginState,
'/oauth2/v1/foo': OAuthState, '/oauth2/v1/foo': OAuthState,
'/oauth2/v1': 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: { auth: {
oauth: { oauth: {
clientId: 'ely.by' clientId: 'ely.by',
prompt: []
} }
} }
}); });
@ -166,7 +167,8 @@ describe('CompleteState', () => {
}, },
auth: { auth: {
oauth: { oauth: {
clientId: 'ely.by' clientId: 'ely.by',
prompt: []
} }
} }
}); });
@ -194,7 +196,8 @@ describe('CompleteState', () => {
}, },
auth: { auth: {
oauth: { oauth: {
clientId: 'ely.by' clientId: 'ely.by',
prompt: []
} }
} }
}); });
@ -225,7 +228,8 @@ describe('CompleteState', () => {
}, },
auth: { auth: {
oauth: { oauth: {
clientId: 'ely.by' clientId: 'ely.by',
prompt: []
} }
} }
}); });
@ -242,21 +246,21 @@ describe('CompleteState', () => {
return promise.catch(mock.verify.bind(mock)); return promise.catch(mock.verify.bind(mock));
}; };
it('should transition to finish state if rejected with static_page', () => { it('should transition to finish state if rejected with static_page', () =>
return testOAuth('resolve', {redirectUri: 'static_page'}, FinishState); testOAuth('resolve', {redirectUri: 'static_page'}, FinishState)
}); );
it('should transition to finish state if rejected with static_page_with_code', () => { it('should transition to finish state if rejected with static_page_with_code', () =>
return testOAuth('resolve', {redirectUri: 'static_page_with_code'}, FinishState); testOAuth('resolve', {redirectUri: 'static_page_with_code'}, FinishState)
}); );
it('should transition to login state if rejected with unauthorized', () => { it('should transition to login state if rejected with unauthorized', () =>
return testOAuth('reject', {unauthorized: true}, LoginState); testOAuth('reject', {unauthorized: true}, LoginState)
}); );
it('should transition to permissions state if rejected with acceptRequired', () => { it('should transition to permissions state if rejected with acceptRequired', () =>
return testOAuth('reject', {acceptRequired: true}, PermissionsState); testOAuth('reject', {acceptRequired: true}, PermissionsState)
}); );
}); });
describe('permissions accept', () => { describe('permissions accept', () => {
@ -285,7 +289,8 @@ describe('CompleteState', () => {
}, },
auth: { auth: {
oauth: { oauth: {
clientId: 'ely.by' clientId: 'ely.by',
prompt: []
} }
} }
}); });
@ -309,7 +314,8 @@ describe('CompleteState', () => {
}, },
auth: { auth: {
oauth: { oauth: {
clientId: 'ely.by' clientId: 'ely.by',
prompt: []
} }
} }
}); });
@ -337,6 +343,7 @@ describe('CompleteState', () => {
auth: { auth: {
oauth: { oauth: {
clientId: 'ely.by', clientId: 'ely.by',
prompt: [],
acceptRequired: true acceptRequired: true
} }
} }
@ -365,6 +372,7 @@ describe('CompleteState', () => {
auth: { auth: {
oauth: { oauth: {
clientId: 'ely.by', clientId: 'ely.by',
prompt: [],
acceptRequired: true acceptRequired: true
} }
} }

View File

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

View File

@ -28,6 +28,8 @@ describe('OAuthState', () => {
response_type: 'response_type', response_type: 'response_type',
description: 'description', description: 'description',
scope: 'scope', scope: 'scope',
prompt: 'none',
login_hint: 1,
state: 'state' state: 'state'
}; };
@ -42,6 +44,8 @@ describe('OAuthState', () => {
responseType: query.response_type, responseType: query.response_type,
description: query.description, description: query.description,
scope: query.scope, scope: query.scope,
prompt: query.prompt,
loginHint: query.login_hint,
state: query.state state: query.state
}) })
).returns({then() {}}); ).returns({then() {}});

View File

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

View File

@ -6,6 +6,7 @@ const webpack = require('webpack');
const loaderUtils = require('loader-utils'); const loaderUtils = require('loader-utils');
const ExtractTextPlugin = require('extract-text-webpack-plugin'); const ExtractTextPlugin = require('extract-text-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin');
const CircularDependencyPlugin = require('circular-dependency-plugin');
const cssUrl = require('webpack-utils/cssUrl'); const cssUrl = require('webpack-utils/cssUrl');
const cssImport = require('postcss-import'); const cssImport = require('postcss-import');
@ -247,7 +248,11 @@ if (isProduction) {
if (!isProduction && !isTest) { if (!isProduction && !isTest) {
webpackConfig.plugins.push( webpackConfig.plugins.push(
new webpack.HotModuleReplacementPlugin(), new webpack.HotModuleReplacementPlugin(),
new webpack.NoErrorsPlugin() new webpack.NoErrorsPlugin(),
new CircularDependencyPlugin({
exclude: /node_modules/,
failOnError: false
})
); );
if (config.apiHost) { if (config.apiHost) {