mirror of
https://github.com/elyby/accounts-frontend.git
synced 2024-10-03 00:37:18 +05:30
Merge branch '48-multy-acc' into develop
This commit is contained in:
commit
63ab3d58e8
@ -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",
|
||||||
|
@ -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');
|
||||||
|
|
||||||
|
5
src/components/accounts/AccountSwitcher.intl.json
Normal file
5
src/components/accounts/AccountSwitcher.intl.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"addAccount": "Add account",
|
||||||
|
"goToEly": "Go to Ely.by profile",
|
||||||
|
"logout": "Log out"
|
||||||
|
}
|
167
src/components/accounts/AccountSwitcher.jsx
Normal file
167
src/components/accounts/AccountSwitcher.jsx
Normal 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);
|
238
src/components/accounts/accountSwitcher.scss
Normal file
238
src/components/accounts/accountSwitcher.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
149
src/components/accounts/actions.js
Normal file
149
src/components/accounts/actions.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
1
src/components/accounts/index.js
Normal file
1
src/components/accounts/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export AccountSwitcher from './AccountSwitcher';
|
82
src/components/accounts/reducer.js
Normal file
82
src/components/accounts/reducer.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
let user = {
|
||||||
|
...state.user
|
||||||
|
};
|
||||||
|
|
||||||
|
if (login) {
|
||||||
|
user = {
|
||||||
|
...user,
|
||||||
|
isGuest: true,
|
||||||
|
email: '',
|
||||||
|
username: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
if (/[@.]/.test(login)) {
|
||||||
|
user.email = login;
|
||||||
|
} else {
|
||||||
|
user.username = login;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
accounts: state.accounts, // need this, to re-render height
|
||||||
auth: state.auth,
|
auth: state.auth,
|
||||||
resolve: authFlow.resolve.bind(authFlow),
|
resolve: authFlow.resolve.bind(authFlow),
|
||||||
reject: authFlow.reject.bind(authFlow)
|
reject: authFlow.reject.bind(authFlow)
|
||||||
}), {
|
};
|
||||||
|
}, {
|
||||||
clearErrors: actions.clearErrors,
|
clearErrors: actions.clearErrors,
|
||||||
setErrors: actions.setErrors
|
setErrors: actions.setErrors
|
||||||
})(PanelTransition);
|
})(PanelTransition);
|
||||||
|
@ -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
|
||||||
|
@ -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) {
|
||||||
|
@ -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}"
|
||||||
|
}
|
16
src/components/auth/chooseAccount/ChooseAccount.jsx
Normal file
16
src/components/auth/chooseAccount/ChooseAccount.jsx
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
43
src/components/auth/chooseAccount/ChooseAccountBody.jsx
Normal file
43
src/components/auth/chooseAccount/ChooseAccountBody.jsx
Normal 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);
|
||||||
|
};
|
||||||
|
}
|
23
src/components/auth/chooseAccount/chooseAccount.scss
Normal file
23
src/components/auth/chooseAccount/chooseAccount.scss
Normal 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;
|
||||||
|
}
|
@ -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
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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}));
|
||||||
|
|
||||||
|
// TODO: probably should be moved from here, because it is a side effect
|
||||||
|
captcha.setLang(locale);
|
||||||
|
|
||||||
|
return locale;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setLocale({locale, messages}) {
|
||||||
|
return {
|
||||||
type: SET_LOCALE,
|
type: SET_LOCALE,
|
||||||
payload: {
|
payload: {
|
||||||
locale,
|
locale,
|
||||||
messages
|
messages
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
return locale;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
@ -56,7 +56,7 @@
|
|||||||
transition: .2s;
|
transition: .2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f5f5f5;
|
background: $whiteButtonLight;
|
||||||
color: #262626;
|
color: #262626;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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 & {
|
||||||
|
@ -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;
|
||||||
|
@ -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);
|
||||||
|
@ -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 : (
|
||||||
|
@ -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}
|
||||||
|
@ -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];
|
||||||
|
@ -15,7 +15,6 @@
|
|||||||
&.is-active {
|
&.is-active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
visibility: visible;
|
visibility: visible;
|
||||||
transition: 0.05s ease;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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) => {
|
||||||
|
@ -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,12 +25,8 @@ 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);
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: probably should be moved from here, because it is side effect
|
|
||||||
captcha.setLang(lang);
|
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: CHANGE_LANG,
|
type: CHANGE_LANG,
|
||||||
@ -36,10 +34,17 @@ export function changeLang(lang) {
|
|||||||
lang
|
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) {
|
|
||||||
authentication.logout();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
setTimeout(() => { // a tiny timeout to allow logout before user's token will be removed
|
|
||||||
dispatch(setUser({
|
dispatch(setUser({
|
||||||
lang: getState().user.lang,
|
lang: getState().user.lang,
|
||||||
isGuest: true
|
isGuest: true
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
dispatch(logoutAll());
|
||||||
|
|
||||||
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;
|
||||||
});
|
});
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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();
|
|
||||||
};
|
|
||||||
}
|
|
98
src/components/userbar/LoggedInPanel.jsx
Normal file
98
src/components/userbar/LoggedInPanel.jsx
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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} як сэрвіс",
|
||||||
|
@ -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",
|
||||||
|
@ -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} как сервис",
|
||||||
|
@ -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.",
|
||||||
|
5
src/icons/webfont/minecraft-character.svg
Normal file
5
src/icons/webfont/minecraft-character.svg
Normal 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 |
7
src/icons/webfont/plus.svg
Normal file
7
src/icons/webfont/plus.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||||
|
<svg version="1.2" baseProfile="tiny" id="Слой_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 |
@ -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';
|
||||||
|
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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);
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
|
@ -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} />
|
||||||
|
@ -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({
|
||||||
|
@ -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;
|
||||||
|
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
25
src/services/authFlow/ChooseAccountState.js
Normal file
25
src/services/authFlow/ChooseAccountState.js
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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()));
|
||||||
}
|
}
|
||||||
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
9
src/services/loader.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export default {
|
||||||
|
show() {
|
||||||
|
document.getElementById('loader').classList.add('is-active');
|
||||||
|
},
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
document.getElementById('loader').classList.remove('is-active');
|
||||||
|
}
|
||||||
|
};
|
@ -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)))
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
245
tests/components/accounts/actions.test.js
Normal file
245
tests/components/accounts/actions.test.js
Normal 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()
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
120
tests/components/accounts/reducer.test.js
Normal file
120
tests/components/accounts/reducer.test.js
Normal 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]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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')]
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
43
tests/components/auth/reducer.test.js
Normal file
43
tests/components/auth/reducer.test.js
Normal 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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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()
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -3,14 +3,25 @@ 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: () => ({
|
||||||
user: {token}
|
...emptyState,
|
||||||
|
accounts: {
|
||||||
|
active: {token}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set Authorization header', () => {
|
||||||
const data = {
|
const data = {
|
||||||
options: {
|
options: {
|
||||||
headers: {}
|
headers: {}
|
||||||
@ -19,15 +30,50 @@ describe('bearerHeaderMiddleware', () => {
|
|||||||
|
|
||||||
middleware.before(data);
|
middleware.before(data);
|
||||||
|
|
||||||
expect(data.options.headers, 'to satisfy', {
|
expectBearerHeader(data, token);
|
||||||
Authorization: `Bearer ${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}
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set Authorization header', () => {
|
||||||
|
const data = {
|
||||||
|
options: {
|
||||||
|
headers: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
middleware.before(data);
|
||||||
|
|
||||||
|
expectBearerHeader(data, 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}`
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
@ -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,26 +17,42 @@ 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: {}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should request new token', () => {
|
||||||
const data = {
|
const data = {
|
||||||
url: 'foo',
|
url: 'foo',
|
||||||
options: {
|
options: {
|
||||||
@ -54,8 +71,116 @@ describe('refreshTokenMiddleware', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not apply to refresh-token request', () => {
|
||||||
|
const data = {url: '/refresh-token', options: {}};
|
||||||
|
const resp = middleware.before(data);
|
||||||
|
|
||||||
|
expect(resp, 'to satisfy', data);
|
||||||
|
|
||||||
|
expect(authentication.requestToken, 'was not called');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not auto refresh token if options.token specified', () => {
|
||||||
|
const data = {
|
||||||
|
url: 'foo',
|
||||||
|
options: {token: 'foo'}
|
||||||
|
};
|
||||||
|
middleware.before(data);
|
||||||
|
|
||||||
|
expect(authentication.requestToken, 'was not called');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update user with new token', () => {
|
||||||
|
const data = {
|
||||||
|
url: 'foo',
|
||||||
|
options: {
|
||||||
|
headers: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
authentication.requestToken.returns(Promise.resolve({token: validToken}));
|
||||||
|
|
||||||
|
return middleware.before(data).then(() =>
|
||||||
|
expect(dispatch, 'to have a call satisfying', [
|
||||||
|
updateToken(validToken)
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should if token can not be parsed', () => {
|
||||||
|
const account = {
|
||||||
|
token: 'realy bad token',
|
||||||
|
refreshToken
|
||||||
|
};
|
||||||
|
getState.returns({
|
||||||
|
accounts: {
|
||||||
|
active: account,
|
||||||
|
available: [account]
|
||||||
|
},
|
||||||
|
user: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const req = {url: 'foo', options: {}};
|
||||||
|
|
||||||
|
return expect(middleware.before(req), 'to be fulfilled with', req).then(() => {
|
||||||
|
expect(authentication.requestToken, 'was not called');
|
||||||
|
|
||||||
|
expect(dispatch, 'to have a call satisfying', [
|
||||||
|
{payload: {isGuest: true}}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should logout if token request failed', () => {
|
||||||
|
authentication.requestToken.returns(Promise.reject());
|
||||||
|
|
||||||
|
return expect(middleware.before({url: 'foo', options: {}}), 'to be fulfilled').then(() =>
|
||||||
|
expect(dispatch, 'to have a call satisfying', [
|
||||||
|
{payload: {isGuest: true}}
|
||||||
|
])
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when token expired legacy user', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getState.returns({
|
||||||
|
accounts: {
|
||||||
|
active: null,
|
||||||
|
available: []
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
token: expiredToken,
|
||||||
|
refreshToken
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should request new token', () => {
|
||||||
|
const data = {
|
||||||
|
url: 'foo',
|
||||||
|
options: {
|
||||||
|
headers: {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
authentication.requestToken.returns(Promise.resolve({token: validToken}));
|
||||||
|
|
||||||
|
return middleware.before(data).then((resp) => {
|
||||||
|
expect(resp, 'to satisfy', data);
|
||||||
|
|
||||||
|
expect(authentication.requestToken, 'to have a call satisfying', [
|
||||||
|
refreshToken
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should not be applied if no token', () => {
|
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');
|
||||||
});
|
})
|
||||||
});
|
);
|
||||||
|
|
||||||
xit('should logout user if token cannot be refreshed'); // TODO: need a way to test, that action 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
126
tests/services/api/authentication.test.js
Normal file
126
tests/services/api/authentication.test.js
Normal 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}
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -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,
|
||||||
|
56
tests/services/authFlow/ChooseAccountState.test.js
Normal file
56
tests/services/authFlow/ChooseAccountState.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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'});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -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() {}});
|
||||||
|
@ -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,14 +47,16 @@ 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(
|
expectRun(
|
||||||
mock,
|
mock,
|
||||||
@ -66,21 +70,6 @@ describe('PasswordState', () => {
|
|||||||
|
|
||||||
state.resolve(context, {password: expectedPassword, rememberMe: expectedRememberMe});
|
state.resolve(context, {password: expectedPassword, rememberMe: expectedRememberMe});
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
testWith({
|
|
||||||
email: expectedLogin
|
|
||||||
});
|
|
||||||
|
|
||||||
testWith({
|
|
||||||
username: expectedLogin
|
|
||||||
});
|
|
||||||
|
|
||||||
testWith({
|
|
||||||
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);
|
||||||
|
@ -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) {
|
||||||
|
Loading…
Reference in New Issue
Block a user