mirror of
https://github.com/elyby/accounts-frontend.git
synced 2024-11-30 10:42:34 +05:30
Merge branch '48-multy-acc' into develop
This commit is contained in:
commit
63ab3d58e8
@ -12,7 +12,7 @@
|
||||
"up": "npm update",
|
||||
"test": "karma start ./karma.conf.js",
|
||||
"lint": "eslint ./src",
|
||||
"i18n": "cd ./scripts && ./node_modules/.bin/babel-node i18n-collect.js",
|
||||
"i18n": "babel-node ./scripts/i18n-collect.js",
|
||||
"build": "rm -rf dist/ && webpack --progress --colors -p"
|
||||
},
|
||||
"dependencies": {
|
||||
@ -33,6 +33,7 @@
|
||||
"react-router": "^2.0.0",
|
||||
"react-router-redux": "^3.0.0",
|
||||
"redux": "^3.0.4",
|
||||
"redux-localstorage": "^0.4.1",
|
||||
"redux-thunk": "^2.0.0",
|
||||
"webfontloader": "^1.6.26",
|
||||
"whatwg-fetch": "^1.0.0"
|
||||
@ -50,6 +51,7 @@
|
||||
"babel-preset-stage-0": "^6.3.13",
|
||||
"babel-runtime": "^6.0.0",
|
||||
"bundle-loader": "^0.5.4",
|
||||
"circular-dependency-plugin": "^2.0.0",
|
||||
"css-loader": "^0.23.0",
|
||||
"enzyme": "^2.2.0",
|
||||
"eslint": "^3.1.1",
|
||||
|
@ -5,8 +5,8 @@ import {sync as mkdirpSync} from 'mkdirp';
|
||||
import chalk from 'chalk';
|
||||
import prompt from 'prompt';
|
||||
|
||||
const MESSAGES_PATTERN = '../dist/messages/**/*.json';
|
||||
const LANG_DIR = '../src/i18n';
|
||||
const MESSAGES_PATTERN = `${__dirname}/../dist/messages/**/*.json`;
|
||||
const LANG_DIR = `${__dirname}/../src/i18n`;
|
||||
const DEFAULT_LOCALE = 'en';
|
||||
const SUPPORTED_LANGS = [DEFAULT_LOCALE].concat('ru', 'be', 'uk');
|
||||
|
||||
|
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'],
|
||||
['register', 'activation', 'resendActivation'],
|
||||
['acceptRules'],
|
||||
['permissions']
|
||||
['chooseAccount', 'permissions']
|
||||
];
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
@ -64,10 +64,7 @@ class PanelTransition extends Component {
|
||||
payload: PropTypes.object
|
||||
})]),
|
||||
isLoading: PropTypes.bool,
|
||||
login: PropTypes.shape({
|
||||
login: PropTypes.string,
|
||||
password: PropTypes.string
|
||||
})
|
||||
login: PropTypes.string
|
||||
}).isRequired,
|
||||
user: userShape.isRequired,
|
||||
setErrors: PropTypes.func.isRequired,
|
||||
@ -89,12 +86,12 @@ class PanelTransition extends Component {
|
||||
type: PropTypes.string,
|
||||
payload: PropTypes.object
|
||||
})]),
|
||||
login: PropTypes.shape({
|
||||
login: PropTypes.string,
|
||||
password: PropTypes.string
|
||||
})
|
||||
login: PropTypes.string
|
||||
}),
|
||||
user: userShape,
|
||||
accounts: PropTypes.shape({
|
||||
available: PropTypes.array
|
||||
}),
|
||||
requestRedraw: PropTypes.func,
|
||||
clearErrors: PropTypes.func,
|
||||
resolve: PropTypes.func,
|
||||
@ -314,7 +311,12 @@ class PanelTransition extends Component {
|
||||
}
|
||||
|
||||
shouldMeasureHeight() {
|
||||
return [this.props.auth.error, this.state.isHeightDirty, this.props.user.lang].join('');
|
||||
return [
|
||||
this.props.auth.error,
|
||||
this.state.isHeightDirty,
|
||||
this.props.user.lang,
|
||||
this.props.accounts.available.length
|
||||
].join('');
|
||||
}
|
||||
|
||||
getHeader({key, style, data}) {
|
||||
@ -446,12 +448,35 @@ class PanelTransition extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect((state) => ({
|
||||
user: state.user,
|
||||
auth: state.auth,
|
||||
resolve: authFlow.resolve.bind(authFlow),
|
||||
reject: authFlow.reject.bind(authFlow)
|
||||
}), {
|
||||
export default connect((state) => {
|
||||
const {login} = state.auth;
|
||||
let user = {
|
||||
...state.user
|
||||
};
|
||||
|
||||
if (login) {
|
||||
user = {
|
||||
...user,
|
||||
isGuest: true,
|
||||
email: '',
|
||||
username: ''
|
||||
};
|
||||
|
||||
if (/[@.]/.test(login)) {
|
||||
user.email = login;
|
||||
} else {
|
||||
user.username = login;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
accounts: state.accounts, // need this, to re-render height
|
||||
auth: state.auth,
|
||||
resolve: authFlow.resolve.bind(authFlow),
|
||||
reject: authFlow.reject.bind(authFlow)
|
||||
};
|
||||
}, {
|
||||
clearErrors: actions.clearErrors,
|
||||
setErrors: actions.setErrors
|
||||
})(PanelTransition);
|
||||
|
@ -2,12 +2,12 @@
|
||||
|
||||
To add new panel you need to:
|
||||
|
||||
* add new state to `services/authFlow` and coresponding test to `tests/services/authFlow`
|
||||
* connect state to `authFlow`. Update `services/authFlow/AuthFlow.test` and `services/authFlow/AuthFlow.functional.test` (the last one for some complex flow)
|
||||
* add new actions to `components/auth/actions` and api endpoints to `services/api`
|
||||
* create panel component at `components/auth/[panelId]`
|
||||
* add new context in `components/auth/PanelTransition`
|
||||
* connect component to `routes`
|
||||
* add new state to `services/authFlow` and coresponding test to `tests/services/authFlow`
|
||||
* connect state to `authFlow`. Update `services/authFlow/AuthFlow.test` and `services/authFlow/AuthFlow.functional.test` (the last one for some complex flow)
|
||||
* add new actions to `components/auth/actions` and api endpoints to `services/api`
|
||||
* whatever else you need
|
||||
|
||||
Commit id with example: f4d315c
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { routeActions } from 'react-router-redux';
|
||||
|
||||
import { updateUser, logout as logoutUser, acceptRules as userAcceptRules, authenticate } from 'components/user/actions';
|
||||
import { updateUser, logout, acceptRules as userAcceptRules } from 'components/user/actions';
|
||||
import { authenticate } from 'components/accounts/actions';
|
||||
import authentication from 'services/api/authentication';
|
||||
import oauth from 'services/api/oauth';
|
||||
import signup from 'services/api/signup';
|
||||
@ -19,24 +20,13 @@ export function login({login = '', password = '', rememberMe = false}) {
|
||||
.catch((resp) => {
|
||||
if (resp.errors) {
|
||||
if (resp.errors.password === PASSWORD_REQUIRED) {
|
||||
let username = '';
|
||||
let email = '';
|
||||
|
||||
if (/[@.]/.test(login)) {
|
||||
email = login;
|
||||
} else {
|
||||
username = login;
|
||||
}
|
||||
|
||||
return dispatch(updateUser({
|
||||
username,
|
||||
email
|
||||
}));
|
||||
return dispatch(setLogin(login));
|
||||
} else if (resp.errors.login === ACTIVATION_REQUIRED) {
|
||||
return dispatch(needActivation());
|
||||
} else if (resp.errors.login === LOGIN_REQUIRED && password) {
|
||||
// TODO: log this case to backend
|
||||
// return to the first step
|
||||
dispatch(logout());
|
||||
return dispatch(logout());
|
||||
}
|
||||
}
|
||||
|
||||
@ -125,7 +115,23 @@ export function resendActivation({email = '', captcha}) {
|
||||
);
|
||||
}
|
||||
|
||||
export const ERROR = 'error';
|
||||
export const SET_LOGIN = 'auth:setLogin';
|
||||
export function setLogin(login) {
|
||||
return {
|
||||
type: SET_LOGIN,
|
||||
payload: login
|
||||
};
|
||||
}
|
||||
|
||||
export const SET_SWITCHER = 'auth:setAccountSwitcher';
|
||||
export function setAccountSwitcher(isOn) {
|
||||
return {
|
||||
type: SET_SWITCHER,
|
||||
payload: isOn
|
||||
};
|
||||
}
|
||||
|
||||
export const ERROR = 'auth:error';
|
||||
export function setErrors(errors) {
|
||||
return {
|
||||
type: ERROR,
|
||||
@ -138,9 +144,8 @@ export function clearErrors() {
|
||||
return setErrors(null);
|
||||
}
|
||||
|
||||
export function logout() {
|
||||
return logoutUser();
|
||||
}
|
||||
export { logout, updateUser } from 'components/user/actions';
|
||||
export { authenticate } from 'components/accounts/actions';
|
||||
|
||||
/**
|
||||
* @param {object} oauthData
|
||||
@ -149,6 +154,13 @@ export function logout() {
|
||||
* @param {string} oauthData.responseType
|
||||
* @param {string} oauthData.description
|
||||
* @param {string} oauthData.scope
|
||||
* @param {string} [oauthData.prompt='none'] - comma-separated list of values to adjust auth flow
|
||||
* Posible values:
|
||||
* * none - default behaviour
|
||||
* * consent - forcibly prompt user for rules acceptance
|
||||
* * select_account - force account choosage, even if user has only one
|
||||
* @param {string} oauthData.loginHint - allows to choose the account, which will be used for auth
|
||||
* The possible values: account id, email, username
|
||||
* @param {string} oauthData.state
|
||||
*
|
||||
* @return {Promise}
|
||||
@ -159,8 +171,17 @@ export function oAuthValidate(oauthData) {
|
||||
return wrapInLoader((dispatch) =>
|
||||
oauth.validate(oauthData)
|
||||
.then((resp) => {
|
||||
let prompt = (oauthData.prompt || 'none').split(',').map((item) => item.trim);
|
||||
if (prompt.includes('none')) {
|
||||
prompt = ['none'];
|
||||
}
|
||||
|
||||
dispatch(setClient(resp.client));
|
||||
dispatch(setOAuthRequest(resp.oAuth));
|
||||
dispatch(setOAuthRequest({
|
||||
...resp.oAuth,
|
||||
prompt: oauthData.prompt || 'none',
|
||||
loginHint: oauthData.loginHint
|
||||
}));
|
||||
dispatch(setScopes(resp.session.scopes));
|
||||
localStorage.setItem('oauthData', JSON.stringify({ // @see services/authFlow/AuthFlow
|
||||
timestamp: Date.now(),
|
||||
@ -226,6 +247,13 @@ export function setClient({id, name, description}) {
|
||||
};
|
||||
}
|
||||
|
||||
export function resetOAuth() {
|
||||
return (dispatch) => {
|
||||
localStorage.removeItem('oauthData');
|
||||
dispatch(setOAuthRequest({}));
|
||||
};
|
||||
}
|
||||
|
||||
export const SET_OAUTH = 'set_oauth';
|
||||
export function setOAuthRequest(oauth) {
|
||||
return {
|
||||
@ -235,6 +263,8 @@ export function setOAuthRequest(oauth) {
|
||||
redirectUrl: oauth.redirect_uri,
|
||||
responseType: oauth.response_type,
|
||||
scope: oauth.scope,
|
||||
prompt: oauth.prompt,
|
||||
loginHint: oauth.loginHint,
|
||||
state: oauth.state
|
||||
}
|
||||
};
|
||||
@ -305,7 +335,14 @@ function needActivation() {
|
||||
}
|
||||
|
||||
function authHandler(dispatch) {
|
||||
return (resp) => dispatch(authenticate(resp.access_token, resp.refresh_token));
|
||||
return (resp) => dispatch(authenticate({
|
||||
token: resp.access_token,
|
||||
refreshToken: resp.refresh_token
|
||||
})).then((resp) => {
|
||||
dispatch(setLogin(null));
|
||||
|
||||
return resp;
|
||||
});
|
||||
}
|
||||
|
||||
function validationErrorsHandler(dispatch, repeatUrl) {
|
||||
|
@ -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 { ERROR, SET_CLIENT, SET_OAUTH, SET_OAUTH_RESULT, SET_SCOPES, SET_LOADING_STATE, REQUIRE_PERMISSIONS_ACCEPT } from './actions';
|
||||
import {
|
||||
ERROR,
|
||||
SET_CLIENT,
|
||||
SET_OAUTH,
|
||||
SET_OAUTH_RESULT,
|
||||
SET_SCOPES,
|
||||
SET_LOADING_STATE,
|
||||
REQUIRE_PERMISSIONS_ACCEPT,
|
||||
SET_LOGIN,
|
||||
SET_SWITCHER
|
||||
} from './actions';
|
||||
|
||||
export default combineReducers({
|
||||
login,
|
||||
error,
|
||||
isLoading,
|
||||
isSwitcherEnabled,
|
||||
client,
|
||||
oauth,
|
||||
scopes
|
||||
@ -19,6 +31,7 @@ function error(
|
||||
if (!error) {
|
||||
throw new Error('Expected payload with error');
|
||||
}
|
||||
|
||||
return payload;
|
||||
|
||||
default:
|
||||
@ -26,6 +39,39 @@ function error(
|
||||
}
|
||||
}
|
||||
|
||||
function login(
|
||||
state = null,
|
||||
{type, payload = null}
|
||||
) {
|
||||
switch (type) {
|
||||
case SET_LOGIN:
|
||||
if (payload !== null && typeof payload !== 'string') {
|
||||
throw new Error('Expected payload with login string or null');
|
||||
}
|
||||
|
||||
return payload;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function isSwitcherEnabled(
|
||||
state = true,
|
||||
{type, payload = false}
|
||||
) {
|
||||
switch (type) {
|
||||
case SET_SWITCHER:
|
||||
if (typeof payload !== 'boolean') {
|
||||
throw new Error('Expected payload of boolean type');
|
||||
}
|
||||
|
||||
return payload;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function isLoading(
|
||||
state = false,
|
||||
@ -68,6 +114,8 @@ function oauth(
|
||||
redirectUrl: payload.redirectUrl,
|
||||
responseType: payload.responseType,
|
||||
scope: payload.scope,
|
||||
prompt: payload.prompt,
|
||||
loginHint: payload.loginHint,
|
||||
state: payload.state
|
||||
};
|
||||
|
||||
|
@ -1,18 +1,26 @@
|
||||
import i18n from 'services/i18n';
|
||||
import captcha from 'services/captcha';
|
||||
|
||||
export const SET_LOCALE = 'SET_LOCALE';
|
||||
export const SET_LOCALE = 'i18n:setLocale';
|
||||
export function setLocale(locale) {
|
||||
return (dispatch) => i18n.require(
|
||||
i18n.detectLanguage(locale)
|
||||
).then(({locale, messages}) => {
|
||||
dispatch({
|
||||
type: SET_LOCALE,
|
||||
payload: {
|
||||
locale,
|
||||
messages
|
||||
}
|
||||
});
|
||||
dispatch(_setLocale({locale, messages}));
|
||||
|
||||
// TODO: probably should be moved from here, because it is a side effect
|
||||
captcha.setLang(locale);
|
||||
|
||||
return locale;
|
||||
});
|
||||
}
|
||||
|
||||
function _setLocale({locale, messages}) {
|
||||
return {
|
||||
type: SET_LOCALE,
|
||||
payload: {
|
||||
locale,
|
||||
messages
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -56,7 +56,7 @@
|
||||
transition: .2s;
|
||||
|
||||
&:hover {
|
||||
background: #f5f5f5;
|
||||
background: $whiteButtonLight;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
|
@ -81,7 +81,7 @@ $formColumnWidth: 416px;
|
||||
.paramEditIcon {
|
||||
composes: pencil from 'components/ui/icons.scss';
|
||||
|
||||
color: $light;
|
||||
color: $white;
|
||||
transition: .4s;
|
||||
|
||||
a:hover & {
|
||||
|
@ -3,6 +3,9 @@
|
||||
}
|
||||
|
||||
.item {
|
||||
// TODO: in some cases we do not need overflow hidden
|
||||
// probably, it is better to create a separate class for children, that will
|
||||
// enable overflow hidden and ellipsis
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
|
@ -42,7 +42,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: не уверен на счёт этого класса. Мб может лучше добавить это как класс-модификатор для .button?
|
||||
.smallButton {
|
||||
composes: button;
|
||||
|
||||
@ -52,20 +51,23 @@
|
||||
line-height: 30px;
|
||||
}
|
||||
|
||||
.black {
|
||||
.white {
|
||||
composes: button;
|
||||
|
||||
background-color: $black;
|
||||
background-color: #fff;
|
||||
color: #444;
|
||||
|
||||
&:hover {
|
||||
background-color: $black-button-light;
|
||||
color: #262626;
|
||||
background-color: $whiteButtonLight;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $black-button-dark;
|
||||
background-color: $whiteButtonDark;
|
||||
}
|
||||
}
|
||||
|
||||
@include button-theme('black', $black);
|
||||
@include button-theme('blue', $blue);
|
||||
@include button-theme('green', $green);
|
||||
@include button-theme('orange', $orange);
|
||||
|
@ -5,13 +5,13 @@ $violet: #6b5b8c;
|
||||
$dark_blue: #28555b;
|
||||
$light_violet: #8b5d79;
|
||||
$orange: #dd8650;
|
||||
$light: #ebe8e1;
|
||||
$white: #ebe8e1;
|
||||
|
||||
$black: #232323;
|
||||
|
||||
$defaultButtonTextColor : #fff;
|
||||
$black-button-light: #392f2c;
|
||||
$black-button-dark: #1e0b11;
|
||||
$whiteButtonLight: #f5f5f5;
|
||||
$whiteButtonDark: #f5f5f5; // TODO: найти оптимальный цвет для прожатого состояния
|
||||
|
||||
@function darker($color) {
|
||||
$elyColorsMap : (
|
||||
|
@ -19,7 +19,9 @@ export default class Button extends FormComponent {
|
||||
PropTypes.string
|
||||
]).isRequired,
|
||||
block: PropTypes.bool,
|
||||
color: PropTypes.oneOf(colors)
|
||||
small: PropTypes.bool,
|
||||
color: PropTypes.oneOf(colors),
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
@ -27,7 +29,7 @@ export default class Button extends FormComponent {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { color, block, small } = this.props;
|
||||
const { color, block, small, className } = this.props;
|
||||
|
||||
const props = omit(this.props, Object.keys(Button.propTypes));
|
||||
|
||||
@ -37,7 +39,7 @@ export default class Button extends FormComponent {
|
||||
<button className={classNames(buttons[color], {
|
||||
[buttons.block]: block,
|
||||
[buttons.smallButton]: small
|
||||
})}
|
||||
}, className)}
|
||||
{...props}
|
||||
>
|
||||
{label}
|
||||
|
@ -8,6 +8,8 @@ export const COLOR_VIOLET = 'violet';
|
||||
export const COLOR_LIGHT_VIOLET = 'lightViolet';
|
||||
export const COLOR_ORANGE = 'orange';
|
||||
export const COLOR_RED = 'red';
|
||||
export const COLOR_BLACK = 'black';
|
||||
export const COLOR_WHITE = 'white';
|
||||
|
||||
export const colors = [
|
||||
COLOR_GREEN,
|
||||
@ -16,7 +18,9 @@ export const colors = [
|
||||
COLOR_VIOLET,
|
||||
COLOR_LIGHT_VIOLET,
|
||||
COLOR_ORANGE,
|
||||
COLOR_RED
|
||||
COLOR_RED,
|
||||
COLOR_BLACK,
|
||||
COLOR_WHITE
|
||||
];
|
||||
|
||||
export const skins = [SKIN_DARK, SKIN_LIGHT];
|
||||
|
@ -15,7 +15,6 @@
|
||||
&.is-active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transition: 0.05s ease;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -76,7 +76,7 @@ $popupMargin: 20px; // Отступ попапа от краёв окна
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
background: $light;
|
||||
background: $white;
|
||||
padding: 15px $popupPadding;
|
||||
border-bottom: 1px solid #dedede;
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ const KEY_USER = 'user';
|
||||
|
||||
export default class User {
|
||||
/**
|
||||
* @param {object|string|undefined} data plain object or jwt token or empty to load from storage
|
||||
* @param {object} [data] - plain object or jwt token or empty to load from storage
|
||||
*
|
||||
* @return {User}
|
||||
*/
|
||||
@ -18,8 +18,6 @@ export default class User {
|
||||
const defaults = {
|
||||
id: null,
|
||||
uuid: null,
|
||||
token: '',
|
||||
refreshToken: '',
|
||||
username: '',
|
||||
email: '',
|
||||
// will contain user's email or masked email
|
||||
@ -27,12 +25,14 @@ export default class User {
|
||||
maskedEmail: '',
|
||||
avatar: '',
|
||||
lang: '',
|
||||
goal: null, // the goal with wich user entered site
|
||||
isGuest: true,
|
||||
isActive: false,
|
||||
shouldAcceptRules: false, // whether user need to review updated rules
|
||||
passwordChangedAt: null,
|
||||
hasMojangUsernameCollision: false,
|
||||
|
||||
// frontend app specific attributes
|
||||
isGuest: true,
|
||||
goal: null, // the goal with wich user entered site
|
||||
};
|
||||
|
||||
const user = Object.keys(defaults).reduce((user, key) => {
|
||||
|
@ -1,14 +1,16 @@
|
||||
import { routeActions } from 'react-router-redux';
|
||||
|
||||
import captcha from 'services/captcha';
|
||||
import accounts from 'services/api/accounts';
|
||||
import { logoutAll } from 'components/accounts/actions';
|
||||
import authentication from 'services/api/authentication';
|
||||
import { setLocale } from 'components/i18n/actions';
|
||||
|
||||
export const UPDATE = 'USER_UPDATE';
|
||||
/**
|
||||
* @param {string|object} payload jwt token or user object
|
||||
* @return {object} action definition
|
||||
* Merge data into user's state
|
||||
*
|
||||
* @param {object} payload
|
||||
* @return {object} - action definition
|
||||
*/
|
||||
export function updateUser(payload) {
|
||||
return {
|
||||
@ -23,23 +25,26 @@ export function changeLang(lang) {
|
||||
.then((lang) => {
|
||||
const {user: {isGuest, lang: oldLang}} = getState();
|
||||
|
||||
if (!isGuest && oldLang !== lang) {
|
||||
accounts.changeLang(lang);
|
||||
if (oldLang !== lang) {
|
||||
!isGuest && accounts.changeLang(lang);
|
||||
|
||||
dispatch({
|
||||
type: CHANGE_LANG,
|
||||
payload: {
|
||||
lang
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: probably should be moved from here, because it is side effect
|
||||
captcha.setLang(lang);
|
||||
|
||||
dispatch({
|
||||
type: CHANGE_LANG,
|
||||
payload: {
|
||||
lang
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const SET = 'USER_SET';
|
||||
/**
|
||||
* Replace current user's state with a new one
|
||||
*
|
||||
* @param {User} payload
|
||||
* @return {object} - action definition
|
||||
*/
|
||||
export function setUser(payload) {
|
||||
return {
|
||||
type: SET,
|
||||
@ -49,22 +54,16 @@ export function setUser(payload) {
|
||||
|
||||
export function logout() {
|
||||
return (dispatch, getState) => {
|
||||
if (getState().user.token) {
|
||||
authentication.logout();
|
||||
}
|
||||
dispatch(setUser({
|
||||
lang: getState().user.lang,
|
||||
isGuest: true
|
||||
}));
|
||||
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => { // a tiny timeout to allow logout before user's token will be removed
|
||||
dispatch(setUser({
|
||||
lang: getState().user.lang,
|
||||
isGuest: true
|
||||
}));
|
||||
dispatch(logoutAll());
|
||||
|
||||
dispatch(routeActions.push('/login'));
|
||||
dispatch(routeActions.push('/login'));
|
||||
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
return Promise.resolve();
|
||||
};
|
||||
}
|
||||
|
||||
@ -72,7 +71,10 @@ export function fetchUserData() {
|
||||
return (dispatch) =>
|
||||
accounts.current()
|
||||
.then((resp) => {
|
||||
dispatch(updateUser(resp));
|
||||
dispatch(updateUser({
|
||||
isGuest: false,
|
||||
...resp
|
||||
}));
|
||||
|
||||
return dispatch(changeLang(resp.lang));
|
||||
});
|
||||
@ -80,31 +82,11 @@ export function fetchUserData() {
|
||||
|
||||
export function acceptRules() {
|
||||
return (dispatch) =>
|
||||
accounts.acceptRules()
|
||||
.then((resp) => {
|
||||
accounts.acceptRules().then((resp) => {
|
||||
dispatch(updateUser({
|
||||
shouldAcceptRules: false
|
||||
}));
|
||||
|
||||
return resp;
|
||||
})
|
||||
;
|
||||
}
|
||||
|
||||
export function authenticate(token, refreshToken) { // TODO: this action, probably, belongs to components/auth
|
||||
return (dispatch, getState) => {
|
||||
refreshToken = refreshToken || getState().user.refreshToken;
|
||||
dispatch(updateUser({
|
||||
token,
|
||||
refreshToken
|
||||
}));
|
||||
|
||||
return dispatch(fetchUserData()).then((resp) => {
|
||||
dispatch(updateUser({
|
||||
isGuest: false
|
||||
}));
|
||||
return resp;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { authenticate, changeLang } from 'components/user/actions';
|
||||
import { changeLang } from 'components/user/actions';
|
||||
import { authenticate } from 'components/accounts/actions';
|
||||
|
||||
import request from 'services/request';
|
||||
import bearerHeaderMiddleware from './middlewares/bearerHeaderMiddleware';
|
||||
@ -22,11 +23,11 @@ export function factory(store) {
|
||||
request.addMiddleware(bearerHeaderMiddleware(store));
|
||||
|
||||
promise = new Promise((resolve, reject) => {
|
||||
const {user} = store.getState();
|
||||
const {user, accounts} = store.getState();
|
||||
|
||||
if (user.token) {
|
||||
if (accounts.active || user.token) {
|
||||
// authorizing user if it is possible
|
||||
return store.dispatch(authenticate(user.token)).then(resolve, reject);
|
||||
return store.dispatch(authenticate(accounts.active || user)).then(resolve, reject);
|
||||
}
|
||||
|
||||
// auto-detect guests language
|
||||
|
@ -8,14 +8,20 @@
|
||||
*/
|
||||
export default function bearerHeaderMiddleware({getState}) {
|
||||
return {
|
||||
before(data) {
|
||||
const {token} = getState().user;
|
||||
before(req) {
|
||||
const {user, accounts} = getState();
|
||||
|
||||
if (token) {
|
||||
data.options.headers.Authorization = `Bearer ${token}`;
|
||||
let {token} = accounts.active ? accounts.active : user;
|
||||
|
||||
if (req.options.token) {
|
||||
token = req.options.token;
|
||||
}
|
||||
|
||||
return data;
|
||||
if (token) {
|
||||
req.options.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return req;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import authentication from 'services/api/authentication';
|
||||
import {updateUser, logout} from '../actions';
|
||||
import { updateToken } from 'components/accounts/actions';
|
||||
import { logout } from '../actions';
|
||||
|
||||
/**
|
||||
* Ensures, that all user's requests have fresh access token
|
||||
@ -12,53 +13,52 @@ import {updateUser, logout} from '../actions';
|
||||
*/
|
||||
export default function refreshTokenMiddleware({dispatch, getState}) {
|
||||
return {
|
||||
before(data) {
|
||||
const {refreshToken, token} = getState().user;
|
||||
const isRefreshTokenRequest = data.url.includes('refresh-token');
|
||||
before(req) {
|
||||
const {user, accounts} = getState();
|
||||
|
||||
if (!token || isRefreshTokenRequest) {
|
||||
return data;
|
||||
let refreshToken;
|
||||
let token;
|
||||
|
||||
const isRefreshTokenRequest = req.url.includes('refresh-token');
|
||||
|
||||
if (accounts.active) {
|
||||
token = accounts.active.token;
|
||||
refreshToken = accounts.active.refreshToken;
|
||||
} else { // #legacy token
|
||||
token = user.token;
|
||||
refreshToken = user.refreshToken;
|
||||
}
|
||||
|
||||
if (!token || req.options.token || isRefreshTokenRequest) {
|
||||
return req;
|
||||
}
|
||||
|
||||
try {
|
||||
const SAFETY_FACTOR = 60; // ask new token earlier to overcome time dissynchronization problem
|
||||
const SAFETY_FACTOR = 300; // ask new token earlier to overcome time dissynchronization problem
|
||||
const jwt = getJWTPayload(token);
|
||||
|
||||
if (jwt.exp - SAFETY_FACTOR < Date.now() / 1000) {
|
||||
return requestAccessToken(refreshToken, dispatch).then(() => data);
|
||||
return requestAccessToken(refreshToken, dispatch).then(() => req);
|
||||
}
|
||||
} catch (err) {
|
||||
dispatch(logout());
|
||||
// console.error('Bad token', err); // TODO: it would be cool to log such things to backend
|
||||
return dispatch(logout()).then(() => req);
|
||||
}
|
||||
|
||||
return data;
|
||||
return Promise.resolve(req);
|
||||
},
|
||||
|
||||
catch(resp, restart) {
|
||||
/*
|
||||
{
|
||||
"name": "Unauthorized",
|
||||
"message": "You are requesting with an invalid credential.",
|
||||
"code": 0,
|
||||
"status": 401,
|
||||
"type": "yii\\web\\UnauthorizedHttpException"
|
||||
}
|
||||
{
|
||||
"name": "Unauthorized",
|
||||
"message": "Token expired",
|
||||
"code": 0,
|
||||
"status": 401,
|
||||
"type": "yii\\web\\UnauthorizedHttpException"
|
||||
}
|
||||
*/
|
||||
if (resp && resp.status === 401) {
|
||||
const {refreshToken} = getState().user;
|
||||
catch(resp, req, restart) {
|
||||
if (resp && resp.status === 401 && !req.options.token) {
|
||||
const {user, accounts} = getState();
|
||||
const {refreshToken} = accounts.active ? accounts.active : user;
|
||||
|
||||
if (resp.message === 'Token expired' && refreshToken) {
|
||||
// request token and retry
|
||||
return requestAccessToken(refreshToken, dispatch).then(restart);
|
||||
}
|
||||
|
||||
dispatch(logout());
|
||||
return dispatch(logout()).then(() => Promise.reject(resp));
|
||||
}
|
||||
|
||||
return Promise.reject(resp);
|
||||
@ -75,9 +75,7 @@ function requestAccessToken(refreshToken, dispatch) {
|
||||
}
|
||||
|
||||
return promise
|
||||
.then(({token}) => dispatch(updateUser({
|
||||
token
|
||||
})))
|
||||
.then(({token}) => dispatch(updateToken(token)))
|
||||
.catch(() => dispatch(logout()));
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.activeAccount {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
|
||||
$border: 1px solid rgba(#fff, .15);
|
||||
border-left: $border;
|
||||
border-right: $border;
|
||||
}
|
||||
|
||||
.activeAccountButton {
|
||||
composes: green from 'components/ui/buttons.scss';
|
||||
}
|
||||
|
||||
.activeAccountExpanded {
|
||||
.activeAccountButton {
|
||||
background-color: darker($green);
|
||||
}
|
||||
|
||||
.accountSwitcherContainer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.expandIcon {
|
||||
transform: rotate(-180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.userIcon {
|
||||
@ -11,12 +39,24 @@
|
||||
padding-right: 5px;
|
||||
}
|
||||
|
||||
.expandIcon {
|
||||
composes: caret from 'components/ui/icons.scss';
|
||||
|
||||
margin-left: 4px;
|
||||
font-size: 6px;
|
||||
color: #CCC;
|
||||
transition: .2s;
|
||||
}
|
||||
|
||||
.userName {
|
||||
|
||||
}
|
||||
|
||||
.logoutIcon {
|
||||
composes: exit from 'components/ui/icons.scss';
|
||||
.accountSwitcherContainer {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: -2px;
|
||||
cursor: auto;
|
||||
|
||||
color: #cdcdcd;
|
||||
display: none;
|
||||
}
|
||||
|
@ -1,4 +1,7 @@
|
||||
{
|
||||
"components.accounts.addAccount": "Дадаць акаўнт",
|
||||
"components.accounts.goToEly": "Перайсці ў профіль Ely.by",
|
||||
"components.accounts.logout": "Выйсці",
|
||||
"components.auth.acceptRules.accept": "Прыняць",
|
||||
"components.auth.acceptRules.declineAndLogout": "Адмовіцца і выйсці",
|
||||
"components.auth.acceptRules.description1": "Мы аднавілі {link}.",
|
||||
@ -15,6 +18,10 @@
|
||||
"components.auth.appInfo.documentation": "дакументацыю",
|
||||
"components.auth.appInfo.goToAuth": "Да аўтарызацыі",
|
||||
"components.auth.appInfo.useItYourself": "Наведайце нашу {link}, каб даведацца, як выкарыстоўваць гэты сэрвіс ў сваіх праектах.",
|
||||
"components.auth.chooseAccount.addAccount": "Увайсці ў другі акаўнт",
|
||||
"components.auth.chooseAccount.chooseAccountTitle": "Выбар акаўнта",
|
||||
"components.auth.chooseAccount.description": "Вы выканалі ўваход у некалькі акаўнтаў. Пазначце, які вы жадаеце выкарыстаць для аўтарызацыі {appName}",
|
||||
"components.auth.chooseAccount.logoutAll": "Выйсці з усіх акаўтаў",
|
||||
"components.auth.finish.authForAppFailed": "Аўтарызацыя для {appName} не атрымалася",
|
||||
"components.auth.finish.authForAppSuccessful": "Аўтарызацыя для {appName} паспяхова выканана",
|
||||
"components.auth.finish.copy": "Скапіяваць",
|
||||
@ -126,7 +133,6 @@
|
||||
"components.profile.projectRules": "правілах праекта",
|
||||
"components.profile.twoFactorAuth": "Двухфактарная аўтэнтыфікацыя",
|
||||
"components.userbar.login": "Уваход",
|
||||
"components.userbar.logout": "Выхад",
|
||||
"components.userbar.register": "Рэгістрацыя",
|
||||
"pages.root.siteName": "Ёly.by",
|
||||
"pages.rules.elyAccountsAsService": "{name} як сэрвіс",
|
||||
|
@ -1,4 +1,7 @@
|
||||
{
|
||||
"components.accounts.addAccount": "Add account",
|
||||
"components.accounts.goToEly": "Go to Ely.by profile",
|
||||
"components.accounts.logout": "Log out",
|
||||
"components.auth.acceptRules.accept": "Accept",
|
||||
"components.auth.acceptRules.declineAndLogout": "Decline and logout",
|
||||
"components.auth.acceptRules.description1": "We have updated our {link}.",
|
||||
@ -15,6 +18,10 @@
|
||||
"components.auth.appInfo.documentation": "documentation",
|
||||
"components.auth.appInfo.goToAuth": "Go to auth",
|
||||
"components.auth.appInfo.useItYourself": "Visit our {link}, to learn how to use this service in you projects.",
|
||||
"components.auth.chooseAccount.addAccount": "Log into another account",
|
||||
"components.auth.chooseAccount.chooseAccountTitle": "Choose an account",
|
||||
"components.auth.chooseAccount.description": "You have logged in into multiple accounts. Please choose the one, you want to use to authorize {appName}",
|
||||
"components.auth.chooseAccount.logoutAll": "Log out from all accounts",
|
||||
"components.auth.finish.authForAppFailed": "Authorization for {appName} was failed",
|
||||
"components.auth.finish.authForAppSuccessful": "Authorization for {appName} was successfully completed",
|
||||
"components.auth.finish.copy": "Copy",
|
||||
@ -126,7 +133,6 @@
|
||||
"components.profile.projectRules": "project rules",
|
||||
"components.profile.twoFactorAuth": "Two factor auth",
|
||||
"components.userbar.login": "Sign in",
|
||||
"components.userbar.logout": "Logout",
|
||||
"components.userbar.register": "Join",
|
||||
"pages.root.siteName": "Ely.by",
|
||||
"pages.rules.elyAccountsAsService": "{name} as service",
|
||||
|
@ -1,4 +1,7 @@
|
||||
{
|
||||
"components.accounts.addAccount": "Добавить акккаунт",
|
||||
"components.accounts.goToEly": "Перейти в профиль Ely.by",
|
||||
"components.accounts.logout": "Выйти",
|
||||
"components.auth.acceptRules.accept": "Принять",
|
||||
"components.auth.acceptRules.declineAndLogout": "Отказаться и выйти",
|
||||
"components.auth.acceptRules.description1": "Мы обновили {link}.",
|
||||
@ -15,6 +18,10 @@
|
||||
"components.auth.appInfo.documentation": "документацию",
|
||||
"components.auth.appInfo.goToAuth": "К авторизации",
|
||||
"components.auth.appInfo.useItYourself": "Посетите нашу {link}, чтобы узнать, как использовать этот сервис в своих проектах.",
|
||||
"components.auth.chooseAccount.addAccount": "Войти в другой аккаунт",
|
||||
"components.auth.chooseAccount.chooseAccountTitle": "Выбор аккаунта",
|
||||
"components.auth.chooseAccount.description": "Вы выполнили вход в несколько аккаунтов. Укажите, какой вы хотите использовать для авторизации {appName}",
|
||||
"components.auth.chooseAccount.logoutAll": "Выйти из всех аккаунтов",
|
||||
"components.auth.finish.authForAppFailed": "Авторизация для {appName} не удалась",
|
||||
"components.auth.finish.authForAppSuccessful": "Авторизация для {appName} успешно выполнена",
|
||||
"components.auth.finish.copy": "Скопировать",
|
||||
@ -126,7 +133,6 @@
|
||||
"components.profile.projectRules": "правилами проекта",
|
||||
"components.profile.twoFactorAuth": "Двухфакторная аутентификация",
|
||||
"components.userbar.login": "Вход",
|
||||
"components.userbar.logout": "Выход",
|
||||
"components.userbar.register": "Регистрация",
|
||||
"pages.root.siteName": "Ely.by",
|
||||
"pages.rules.elyAccountsAsService": "{name} как сервис",
|
||||
|
@ -1,4 +1,7 @@
|
||||
{
|
||||
"components.accounts.addAccount": "Додати акаунт",
|
||||
"components.accounts.goToEly": "Профіль на Ely.by",
|
||||
"components.accounts.logout": "Вихід",
|
||||
"components.auth.acceptRules.accept": "Прийняти",
|
||||
"components.auth.acceptRules.declineAndLogout": "Відмовитись і вийти",
|
||||
"components.auth.acceptRules.description1": "Ми оновили наші {link}.",
|
||||
@ -15,6 +18,10 @@
|
||||
"components.auth.appInfo.documentation": "документацію",
|
||||
"components.auth.appInfo.goToAuth": "До авторизації",
|
||||
"components.auth.appInfo.useItYourself": "Відвідайте нашу {link}, щоб дізнатися, як використовувати цей сервіс в своїх проектах.",
|
||||
"components.auth.chooseAccount.addAccount": "Увійти в інший акаунт",
|
||||
"components.auth.chooseAccount.chooseAccountTitle": "Оберіть акаунт",
|
||||
"components.auth.chooseAccount.description": "Ви увійшли у декілька акаунтів. Будь ласка, оберіть акаунт, який ви бажаєте використовувати для авторизації {appName}",
|
||||
"components.auth.chooseAccount.logoutAll": "Вийти з усіх аккаунтів",
|
||||
"components.auth.finish.authForAppFailed": "Авторизація для {appName} не вдалася",
|
||||
"components.auth.finish.authForAppSuccessful": "Авторизація для {appName} успішно виконана",
|
||||
"components.auth.finish.copy": "Скопіювати",
|
||||
@ -126,7 +133,6 @@
|
||||
"components.profile.projectRules": "правилами проекта",
|
||||
"components.profile.twoFactorAuth": "Двофакторна аутентифікація",
|
||||
"components.userbar.login": "Вхід",
|
||||
"components.userbar.logout": "Вихід",
|
||||
"components.userbar.register": "Реєстрація",
|
||||
"pages.root.siteName": "Ely.by",
|
||||
"pages.rules.elyAccountsAsService": "{name} як сервіс",
|
||||
@ -140,6 +146,7 @@
|
||||
"pages.rules.emailAndNickname3": "На призначений для користувача нікнейм, який використовується у грі, не накладаються будь-які моральні обмеження.",
|
||||
"pages.rules.emailAndNickname4": "Ніки, що належать відомим особистостям, за вимогою, можуть бути звільнені у їх користь після встановленню цієї самої особистості.",
|
||||
"pages.rules.emailAndNickname5": "Власник преміум-аккаунта Minecraft має право вимагати відновлення контролю над своїм ніком. У цьому випадку вам необхідно буде протягом 3-х днів змінити нік, або це буде зроблено автоматично.",
|
||||
"pages.rules.emailAndNickname6": "Якщо на вашому акаунті не було активності протягом останніх 3 місяців, ваш нік може будти зайнятий іншим користовичем.",
|
||||
"pages.rules.emailAndNickname7": "Ми не несемо відповідальності за втрачений прогрес на ігрових серверах у результаті зміни ника, включаючи випадки зміни ника на вимогу з нашого боку.",
|
||||
"pages.rules.mainProvision1": "Сервіс {name} призначений для організації безпечного доступу до призначених для користувача аккаунтів проекту Ely.by, його партнерів і будь-яких сторонніх проектів, які бажають використовувати один з наших сервісів.",
|
||||
"pages.rules.mainProvision2": "Ми (тут і надалі) — команда розробників проекту Ely.by, що займаються створенням якісних сервісів для спільноти Minecraft.",
|
||||
|
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 storeFactory from 'storeFactory';
|
||||
import bsodFactory from 'components/ui/bsod/factory';
|
||||
import loader from 'services/loader';
|
||||
|
||||
const store = storeFactory();
|
||||
|
||||
@ -52,7 +53,7 @@ Promise.all([
|
||||
|
||||
|
||||
function stopLoading() {
|
||||
document.getElementById('loader').classList.remove('is-active');
|
||||
loader.hide();
|
||||
}
|
||||
|
||||
import scrollTo from 'components/ui/scrollTo';
|
||||
@ -89,7 +90,10 @@ function restoreScroll() {
|
||||
/* global process: false */
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
// some shortcuts for testing on localhost
|
||||
window.testOAuth = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email';
|
||||
window.testOAuth = (loginHint = '') => location.href = `/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&login_hint=${loginHint}`;
|
||||
window.testOAuthPromptAccount = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&prompt=select_account';
|
||||
window.testOAuthPromptPermissions = (loginHint = '') => location.href = `/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&prompt=consent&login_hint=${loginHint}`;
|
||||
window.testOAuthPromptAll = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&prompt=select_account,consent';
|
||||
window.testOAuthStatic = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=static_page_with_code&response_type=code&scope=account_info%2Caccount_email';
|
||||
window.testOAuthStaticCode = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=static_page&response_type=code&scope=account_info%2Caccount_email';
|
||||
|
||||
|
@ -11,7 +11,7 @@ body,
|
||||
|
||||
body {
|
||||
font-family: $font-family-base;
|
||||
background: $light;
|
||||
background: $white;
|
||||
color: #444;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
@ -31,12 +31,11 @@ function RootPage(props) {
|
||||
})}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.headerContent}>
|
||||
<Link to="/" className={styles.logo}>
|
||||
<Link to="/" className={styles.logo} onClick={props.resetOAuth}>
|
||||
<Message {...messages.siteName} />
|
||||
</Link>
|
||||
<div className={styles.userbar}>
|
||||
<Userbar {...props}
|
||||
onLogout={props.logout}
|
||||
guestAction={isRegisterPage ? 'login' : 'register'}
|
||||
/>
|
||||
</div>
|
||||
@ -58,16 +57,16 @@ RootPage.propTypes = {
|
||||
pathname: PropTypes.string
|
||||
}).isRequired,
|
||||
children: PropTypes.element,
|
||||
logout: PropTypes.func.isRequired,
|
||||
resetOAuth: PropTypes.func.isRequired,
|
||||
isPopupActive: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
import { logout } from 'components/user/actions';
|
||||
import { resetOAuth } from 'components/auth/actions';
|
||||
|
||||
export default connect((state) => ({
|
||||
user: state.user,
|
||||
isPopupActive: state.popup.popups.length > 0
|
||||
}), {
|
||||
logout
|
||||
resetOAuth
|
||||
})(RootPage);
|
||||
|
@ -74,7 +74,7 @@
|
||||
left: -40px;
|
||||
width: calc(100% + 60px);
|
||||
height: calc(100% + 20px);
|
||||
background: $light;
|
||||
background: $white;
|
||||
border-left: $border;
|
||||
border-right: $border;
|
||||
box-sizing: border-box;
|
||||
|
@ -4,6 +4,7 @@ import { routeReducer } from 'react-router-redux';
|
||||
|
||||
import auth from 'components/auth/reducer';
|
||||
import user from 'components/user/reducer';
|
||||
import accounts from 'components/accounts/reducer';
|
||||
import i18n from 'components/i18n/reducer';
|
||||
import popup from 'components/ui/popup/reducer';
|
||||
import bsod from 'components/ui/bsod/reducer';
|
||||
@ -12,6 +13,7 @@ export default combineReducers({
|
||||
bsod,
|
||||
auth,
|
||||
user,
|
||||
accounts,
|
||||
i18n,
|
||||
popup,
|
||||
routing: routeReducer
|
||||
|
@ -17,6 +17,7 @@ import OAuthInit from 'components/auth/OAuthInit';
|
||||
import Register from 'components/auth/register/Register';
|
||||
import Login from 'components/auth/login/Login';
|
||||
import Permissions from 'components/auth/permissions/Permissions';
|
||||
import ChooseAccount from 'components/auth/chooseAccount/ChooseAccount';
|
||||
import Activation from 'components/auth/activation/Activation';
|
||||
import ResendActivation from 'components/auth/resendActivation/ResendActivation';
|
||||
import Password from 'components/auth/password/Password';
|
||||
@ -62,6 +63,7 @@ export default function routesFactory(store) {
|
||||
<Route path="/activation(/:key)" components={new Activation()} {...startAuthFlow} />
|
||||
<Route path="/resend-activation" components={new ResendActivation()} {...startAuthFlow} />
|
||||
<Route path="/oauth/permissions" components={new Permissions()} {...startAuthFlow} />
|
||||
<Route path="/oauth/choose-account" components={new ChooseAccount()} {...startAuthFlow} />
|
||||
<Route path="/oauth/finish" component={Finish} {...startAuthFlow} />
|
||||
<Route path="/accept-rules" components={new AcceptRules()} {...startAuthFlow} />
|
||||
<Route path="/forgot-password" components={new ForgotPassword()} {...startAuthFlow} />
|
||||
|
@ -1,8 +1,17 @@
|
||||
import request from 'services/request';
|
||||
|
||||
export default {
|
||||
current() {
|
||||
return request.get('/api/accounts/current');
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {object} [options.token] - an optional token to overwrite headers
|
||||
* in middleware and disable token auto-refresh
|
||||
*
|
||||
* @return {Promise<User>}
|
||||
*/
|
||||
current(options = {}) {
|
||||
return request.get('/api/accounts/current', {}, {
|
||||
token: options.token
|
||||
});
|
||||
},
|
||||
|
||||
changePassword({
|
||||
|
@ -1,6 +1,7 @@
|
||||
import request from 'services/request';
|
||||
import accounts from 'services/api/accounts';
|
||||
|
||||
export default {
|
||||
const authentication = {
|
||||
login({
|
||||
login = '',
|
||||
password = '',
|
||||
@ -12,8 +13,17 @@ export default {
|
||||
);
|
||||
},
|
||||
|
||||
logout() {
|
||||
return request.post('/api/authentication/logout');
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {object} [options.token] - an optional token to overwrite headers
|
||||
* in middleware and disable token auto-refresh
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
logout(options = {}) {
|
||||
return request.post('/api/authentication/logout', {}, {
|
||||
token: options.token
|
||||
});
|
||||
},
|
||||
|
||||
forgotPassword({
|
||||
@ -36,6 +46,40 @@ export default {
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Resolves if token is valid
|
||||
*
|
||||
* @param {object} options
|
||||
* @param {string} options.token
|
||||
* @param {string} options.refreshToken
|
||||
*
|
||||
* @return {Promise} - resolves with options.token or with a new token
|
||||
* if it was refreshed
|
||||
*/
|
||||
validateToken({token, refreshToken}) {
|
||||
return new Promise((resolve) => {
|
||||
if (typeof token !== 'string') {
|
||||
throw new Error('token must be a string');
|
||||
}
|
||||
|
||||
if (typeof refreshToken !== 'string') {
|
||||
throw new Error('refreshToken must be a string');
|
||||
}
|
||||
|
||||
resolve();
|
||||
})
|
||||
.then(() => accounts.current({token}))
|
||||
.then(() => ({token, refreshToken}))
|
||||
.catch((resp) => {
|
||||
if (resp.message === 'Token expired') {
|
||||
return authentication.requestToken(refreshToken)
|
||||
.then(({token}) => ({token, refreshToken}));
|
||||
}
|
||||
|
||||
return Promise.reject(resp);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Request new access token using a refreshToken
|
||||
*
|
||||
@ -52,3 +96,5 @@ export default {
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
export default authentication;
|
||||
|
@ -57,6 +57,8 @@ function getOAuthRequest(oauthData) {
|
||||
response_type: oauthData.responseType,
|
||||
description: oauthData.description,
|
||||
scope: oauthData.scope,
|
||||
prompt: oauthData.prompt,
|
||||
login_hint: oauthData.loginHint,
|
||||
state: oauthData.state
|
||||
};
|
||||
}
|
||||
|
@ -158,6 +158,7 @@ export default class AuthFlow {
|
||||
case '/accept-rules':
|
||||
case '/oauth/permissions':
|
||||
case '/oauth/finish':
|
||||
case '/oauth/choose-account':
|
||||
this.setState(new LoginState());
|
||||
break;
|
||||
|
||||
@ -191,8 +192,8 @@ export default class AuthFlow {
|
||||
* @return {bool} - whether oauth state is being restored
|
||||
*/
|
||||
restoreOAuthState() {
|
||||
if (this.getRequest().path.indexOf('/register') === 0) {
|
||||
// allow register
|
||||
if (/^\/(register|oauth2)/.test(this.getRequest().path)) {
|
||||
// allow register or the new oauth requests
|
||||
return;
|
||||
}
|
||||
|
||||
|
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 LoginState from './LoginState';
|
||||
import PermissionsState from './PermissionsState';
|
||||
import ChooseAccountState from './ChooseAccountState';
|
||||
import ActivationState from './ActivationState';
|
||||
import AcceptRulesState from './AcceptRulesState';
|
||||
import FinishState from './FinishState';
|
||||
|
||||
const PROMPT_ACCOUNT_CHOOSE = 'select_account';
|
||||
const PROMPT_PERMISSIONS = 'consent';
|
||||
|
||||
export default class CompleteState extends AbstractState {
|
||||
constructor(options = {}) {
|
||||
super(options);
|
||||
@ -13,7 +17,7 @@ export default class CompleteState extends AbstractState {
|
||||
}
|
||||
|
||||
enter(context) {
|
||||
const {auth = {}, user} = context.getState();
|
||||
const {auth = {}, user, accounts} = context.getState();
|
||||
|
||||
if (user.isGuest) {
|
||||
context.setState(new LoginState());
|
||||
@ -22,13 +26,41 @@ export default class CompleteState extends AbstractState {
|
||||
} else if (user.shouldAcceptRules) {
|
||||
context.setState(new AcceptRulesState());
|
||||
} else if (auth.oauth && auth.oauth.clientId) {
|
||||
if (auth.oauth.code) {
|
||||
let isSwitcherEnabled = auth.isSwitcherEnabled;
|
||||
|
||||
if (auth.oauth.loginHint) {
|
||||
const account = accounts.available.filter((account) =>
|
||||
account.id === auth.oauth.loginHint * 1
|
||||
|| account.email === auth.oauth.loginHint
|
||||
|| account.username === auth.oauth.loginHint
|
||||
)[0];
|
||||
|
||||
if (account) {
|
||||
// disable switching, because we are know the account, user must be authorized with
|
||||
context.run('setAccountSwitcher', false);
|
||||
isSwitcherEnabled = false;
|
||||
|
||||
if (account.id !== accounts.active.id) {
|
||||
// lets switch user to an account, that is needed for auth
|
||||
return context.run('authenticate', account)
|
||||
.then(() => context.setState(new CompleteState()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isSwitcherEnabled
|
||||
&& (accounts.available.length > 1
|
||||
|| auth.oauth.prompt.includes(PROMPT_ACCOUNT_CHOOSE)
|
||||
)
|
||||
) {
|
||||
context.setState(new ChooseAccountState());
|
||||
} else if (auth.oauth.code) {
|
||||
context.setState(new FinishState());
|
||||
} else {
|
||||
const data = {};
|
||||
if (typeof this.isPermissionsAccepted !== 'undefined') {
|
||||
data.accept = this.isPermissionsAccepted;
|
||||
} else if (auth.oauth.acceptRequired) {
|
||||
} else if (auth.oauth.acceptRequired || auth.oauth.prompt.includes(PROMPT_PERMISSIONS)) {
|
||||
context.setState(new PermissionsState());
|
||||
return;
|
||||
}
|
||||
|
@ -3,12 +3,18 @@ import PasswordState from './PasswordState';
|
||||
|
||||
export default class LoginState extends AbstractState {
|
||||
enter(context) {
|
||||
const {user} = context.getState();
|
||||
const {auth, user} = context.getState();
|
||||
|
||||
if (user.email || user.username) {
|
||||
// TODO: it may not allow user to leave password state till he click back or enters password
|
||||
if (auth.login) {
|
||||
context.setState(new PasswordState());
|
||||
} else {
|
||||
} else if (user.isGuest
|
||||
// for the case, when user is logged in and wants to add a new aacount
|
||||
|| /login|password/.test(context.getRequest().path) // TODO: improve me
|
||||
) {
|
||||
context.navigate('/login');
|
||||
} else {
|
||||
context.setState(new PasswordState());
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -11,6 +11,8 @@ export default class OAuthState extends AbstractState {
|
||||
responseType: query.response_type,
|
||||
description: query.description,
|
||||
scope: query.scope,
|
||||
prompt: query.prompt,
|
||||
loginHint: query.login_hint,
|
||||
state: query.state
|
||||
}).then(() => context.setState(new CompleteState()));
|
||||
}
|
||||
|
@ -5,9 +5,9 @@ import LoginState from './LoginState';
|
||||
|
||||
export default class PasswordState extends AbstractState {
|
||||
enter(context) {
|
||||
const {user} = context.getState();
|
||||
const {auth} = context.getState();
|
||||
|
||||
if (user.isGuest) {
|
||||
if (auth.login) {
|
||||
context.navigate('/password');
|
||||
} else {
|
||||
context.setState(new CompleteState());
|
||||
@ -15,12 +15,12 @@ export default class PasswordState extends AbstractState {
|
||||
}
|
||||
|
||||
resolve(context, {password, rememberMe}) {
|
||||
const {user} = context.getState();
|
||||
const {auth: {login}} = context.getState();
|
||||
|
||||
context.run('login', {
|
||||
password,
|
||||
rememberMe,
|
||||
login: user.email || user.username
|
||||
login
|
||||
})
|
||||
.then(() => context.setState(new CompleteState()));
|
||||
}
|
||||
@ -30,7 +30,7 @@ export default class PasswordState extends AbstractState {
|
||||
}
|
||||
|
||||
goBack(context) {
|
||||
context.run('logout');
|
||||
context.run('setLogin', null);
|
||||
context.setState(new LoginState());
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,9 @@
|
||||
import AuthFlow from './AuthFlow';
|
||||
|
||||
import * as actions from 'components/auth/actions';
|
||||
import {updateUser} from 'components/user/actions';
|
||||
|
||||
const availableActions = {
|
||||
...actions,
|
||||
updateUser
|
||||
...actions
|
||||
};
|
||||
|
||||
export default new AuthFlow(availableActions);
|
||||
|
9
src/services/loader.js
Normal file
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 {
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {object} data
|
||||
* @param {object} data - request data
|
||||
* @param {object} options - additional options for fetch or middlewares
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
post(url, data) {
|
||||
post(url, data, options = {}) {
|
||||
return doFetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||
},
|
||||
body: buildQuery(data)
|
||||
body: buildQuery(data),
|
||||
...options
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {object} data
|
||||
* @param {object} data - request data
|
||||
* @param {object} options - additional options for fetch or middlewares
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
get(url, data) {
|
||||
if (typeof data === 'object') {
|
||||
get(url, data, options = {}) {
|
||||
if (typeof data === 'object' && Object.keys(data).length) {
|
||||
const separator = url.indexOf('?') === -1 ? '?' : '&';
|
||||
url += separator + buildQuery(data);
|
||||
}
|
||||
|
||||
return doFetch(url);
|
||||
return doFetch(url, options);
|
||||
},
|
||||
|
||||
/**
|
||||
@ -82,8 +85,8 @@ function doFetch(url, options = {}) {
|
||||
.then(checkStatus)
|
||||
.then(toJSON, rejectWithJSON)
|
||||
.then(handleResponseSuccess)
|
||||
.then((resp) => middlewareLayer.run('then', resp))
|
||||
.catch((resp) => middlewareLayer.run('catch', resp, () => doFetch(url, options)))
|
||||
.then((resp) => middlewareLayer.run('then', resp, {url, options}))
|
||||
.catch((resp) => middlewareLayer.run('catch', resp, {url, options}, () => doFetch(url, options)))
|
||||
;
|
||||
}
|
||||
|
||||
|
@ -4,6 +4,7 @@ import { createStore, applyMiddleware, compose } from 'redux';
|
||||
// а также дает возможность проверить какие-либо условия перед запуском экшена
|
||||
// или даже вообще его не запускать в зависимости от условий
|
||||
import thunk from 'redux-thunk';
|
||||
import persistState from 'redux-localstorage';
|
||||
import { syncHistory } from 'react-router-redux';
|
||||
import { browserHistory } from 'react-router';
|
||||
|
||||
@ -15,14 +16,18 @@ export default function storeFactory() {
|
||||
reduxRouterMiddleware,
|
||||
thunk
|
||||
);
|
||||
const persistStateEnhancer = persistState([
|
||||
'accounts',
|
||||
'user'
|
||||
], {key: 'redux-storage'});
|
||||
|
||||
/* global process: false */
|
||||
let enhancer;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
enhancer = compose(middlewares);
|
||||
enhancer = compose(middlewares, persistStateEnhancer);
|
||||
} else {
|
||||
const DevTools = require('containers/DevTools').default;
|
||||
enhancer = compose(middlewares, DevTools.instrument());
|
||||
enhancer = compose(middlewares, persistStateEnhancer, DevTools.instrument());
|
||||
}
|
||||
|
||||
const store = createStore(reducers, {}, enhancer);
|
||||
|
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,
|
||||
setScopes,
|
||||
setOAuthCode,
|
||||
requirePermissionsAccept
|
||||
requirePermissionsAccept,
|
||||
login,
|
||||
setLogin
|
||||
} from 'components/auth/actions';
|
||||
|
||||
const oauthData = {
|
||||
@ -22,8 +24,8 @@ const oauthData = {
|
||||
};
|
||||
|
||||
describe('components/auth/actions', () => {
|
||||
const dispatch = sinon.stub().named('dispatch');
|
||||
const getState = sinon.stub().named('getState');
|
||||
const dispatch = sinon.stub().named('store.dispatch');
|
||||
const getState = sinon.stub().named('store.getState');
|
||||
|
||||
function callThunk(fn, ...args) {
|
||||
const thunk = fn(...args);
|
||||
@ -67,21 +69,25 @@ describe('components/auth/actions', () => {
|
||||
request.get.returns(Promise.resolve(resp));
|
||||
});
|
||||
|
||||
it('should send get request to an api', () => {
|
||||
return callThunk(oAuthValidate, oauthData).then(() => {
|
||||
it('should send get request to an api', () =>
|
||||
callThunk(oAuthValidate, oauthData).then(() => {
|
||||
expect(request.get, 'to have a call satisfying', ['/api/oauth2/v1/validate', {}]);
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
it('should dispatch setClient, setOAuthRequest and setScopes', () => {
|
||||
return callThunk(oAuthValidate, oauthData).then(() => {
|
||||
it('should dispatch setClient, setOAuthRequest and setScopes', () =>
|
||||
callThunk(oAuthValidate, oauthData).then(() => {
|
||||
expectDispatchCalls([
|
||||
[setClient(resp.client)],
|
||||
[setOAuthRequest(resp.oAuth)],
|
||||
[setOAuthRequest({
|
||||
...resp.oAuth,
|
||||
prompt: 'none',
|
||||
loginHint: undefined
|
||||
})],
|
||||
[setScopes(resp.session.scopes)]
|
||||
]);
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('#oAuthComplete()', () => {
|
||||
@ -100,7 +106,7 @@ describe('components/auth/actions', () => {
|
||||
|
||||
return callThunk(oAuthComplete).then(() => {
|
||||
expect(request.post, 'to have a call satisfying', [
|
||||
'/api/oauth2/v1/complete?client_id=&redirect_uri=&response_type=&description=&scope=&state=',
|
||||
'/api/oauth2/v1/complete?client_id=&redirect_uri=&response_type=&description=&scope=&prompt=&login_hint=&state=',
|
||||
{}
|
||||
]);
|
||||
});
|
||||
@ -160,4 +166,24 @@ describe('components/auth/actions', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#login()', () => {
|
||||
describe('when correct login was entered', () => {
|
||||
beforeEach(() => {
|
||||
request.post.returns(Promise.reject({
|
||||
errors: {
|
||||
password: 'error.password_required'
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
it('should set login', () =>
|
||||
callThunk(login, {login: 'foo'}).then(() => {
|
||||
expectDispatchCalls([
|
||||
[setLogin('foo')]
|
||||
]);
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
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 request from 'services/request';
|
||||
import { reset, RESET } from 'components/accounts/actions';
|
||||
|
||||
import {
|
||||
logout,
|
||||
@ -11,8 +12,10 @@ import {
|
||||
|
||||
|
||||
describe('components/user/actions', () => {
|
||||
const dispatch = sinon.stub().named('dispatch');
|
||||
const getState = sinon.stub().named('getState');
|
||||
const getState = sinon.stub().named('store.getState');
|
||||
const dispatch = sinon.spy((arg) =>
|
||||
typeof arg === 'function' ? arg(dispatch, getState) : arg
|
||||
).named('store.dispatch');
|
||||
|
||||
const callThunk = function(fn, ...args) {
|
||||
const thunk = fn(...args);
|
||||
@ -39,11 +42,16 @@ describe('components/user/actions', () => {
|
||||
});
|
||||
|
||||
describe('user with jwt', () => {
|
||||
const token = 'iLoveRockNRoll';
|
||||
|
||||
beforeEach(() => {
|
||||
getState.returns({
|
||||
user: {
|
||||
token: 'iLoveRockNRoll',
|
||||
lang: 'foo'
|
||||
},
|
||||
accounts: {
|
||||
active: {token},
|
||||
available: [{token}]
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -62,20 +70,27 @@ describe('components/user/actions', () => {
|
||||
|
||||
return callThunk(logout).then(() => {
|
||||
expect(request.post, 'to have a call satisfying', [
|
||||
'/api/authentication/logout'
|
||||
'/api/authentication/logout', {}, {}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
testChangedToGuest();
|
||||
testAccountsReset();
|
||||
testRedirectedToLogin();
|
||||
});
|
||||
|
||||
describe('user without jwt', () => { // (a guest with partially filled user's state)
|
||||
describe('user without jwt', () => {
|
||||
// (a guest with partially filled user's state)
|
||||
// DEPRECATED
|
||||
beforeEach(() => {
|
||||
getState.returns({
|
||||
user: {
|
||||
lang: 'foo'
|
||||
},
|
||||
accounts: {
|
||||
active: null,
|
||||
available: []
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -87,6 +102,7 @@ describe('components/user/actions', () => {
|
||||
);
|
||||
|
||||
testChangedToGuest();
|
||||
testAccountsReset();
|
||||
testRedirectedToLogin();
|
||||
});
|
||||
|
||||
@ -112,5 +128,15 @@ describe('components/user/actions', () => {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function testAccountsReset() {
|
||||
it(`should dispatch ${RESET}`, () =>
|
||||
callThunk(logout).then(() => {
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
reset()
|
||||
]);
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -3,31 +3,77 @@ import expect from 'unexpected';
|
||||
import bearerHeaderMiddleware from 'components/user/middlewares/bearerHeaderMiddleware';
|
||||
|
||||
describe('bearerHeaderMiddleware', () => {
|
||||
it('should set Authorization header', () => {
|
||||
const emptyState = {
|
||||
user: {},
|
||||
accounts: {
|
||||
active: null
|
||||
}
|
||||
};
|
||||
|
||||
describe('when token available', () => {
|
||||
const token = 'foo';
|
||||
const middleware = bearerHeaderMiddleware({
|
||||
getState: () => ({
|
||||
...emptyState,
|
||||
accounts: {
|
||||
active: {token}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
it('should set Authorization header', () => {
|
||||
const data = {
|
||||
options: {
|
||||
headers: {}
|
||||
}
|
||||
};
|
||||
|
||||
middleware.before(data);
|
||||
|
||||
expectBearerHeader(data, token);
|
||||
});
|
||||
|
||||
it('overrides user.token with options.token if available', () => {
|
||||
const tokenOverride = 'tokenOverride';
|
||||
const data = {
|
||||
options: {
|
||||
headers: {},
|
||||
token: tokenOverride
|
||||
}
|
||||
};
|
||||
|
||||
middleware.before(data);
|
||||
|
||||
expectBearerHeader(data, tokenOverride);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when legacy token available', () => {
|
||||
const token = 'foo';
|
||||
const middleware = bearerHeaderMiddleware({
|
||||
getState: () => ({
|
||||
...emptyState,
|
||||
user: {token}
|
||||
})
|
||||
});
|
||||
|
||||
const data = {
|
||||
options: {
|
||||
headers: {}
|
||||
}
|
||||
};
|
||||
it('should set Authorization header', () => {
|
||||
const data = {
|
||||
options: {
|
||||
headers: {}
|
||||
}
|
||||
};
|
||||
|
||||
middleware.before(data);
|
||||
middleware.before(data);
|
||||
|
||||
expect(data.options.headers, 'to satisfy', {
|
||||
Authorization: `Bearer ${token}`
|
||||
expectBearerHeader(data, token);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not set Authorization header if no token', () => {
|
||||
const middleware = bearerHeaderMiddleware({
|
||||
getState: () => ({
|
||||
user: {}
|
||||
...emptyState
|
||||
})
|
||||
});
|
||||
|
||||
@ -41,4 +87,10 @@ describe('bearerHeaderMiddleware', () => {
|
||||
|
||||
expect(data.options.headers.Authorization, 'to be undefined');
|
||||
});
|
||||
|
||||
function expectBearerHeader(data, token) {
|
||||
expect(data.options.headers, 'to satisfy', {
|
||||
Authorization: `Bearer ${token}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
@ -3,6 +3,7 @@ import expect from 'unexpected';
|
||||
import refreshTokenMiddleware from 'components/user/middlewares/refreshTokenMiddleware';
|
||||
|
||||
import authentication from 'services/api/authentication';
|
||||
import { updateToken } from 'components/accounts/actions';
|
||||
|
||||
const refreshToken = 'foo';
|
||||
const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0NzA3NjE0NDMsImV4cCI6MTQ3MDc2MTQ0MywiaWF0IjoxNDcwNzYxNDQzLCJqdGkiOiJpZDEyMzQ1NiJ9.gWdnzfQQvarGpkbldUvB8qdJZSVkvdNtCbhbbl2yJW8';
|
||||
@ -16,46 +17,170 @@ describe('refreshTokenMiddleware', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
sinon.stub(authentication, 'requestToken').named('authentication.requestToken');
|
||||
sinon.stub(authentication, 'logout').named('authentication.logout');
|
||||
|
||||
getState = sinon.stub().named('store.getState');
|
||||
dispatch = sinon.stub().named('store.dispatch');
|
||||
dispatch = sinon.spy((arg) =>
|
||||
typeof arg === 'function' ? arg(dispatch, getState) : arg
|
||||
).named('store.dispatch');
|
||||
|
||||
middleware = refreshTokenMiddleware({getState, dispatch});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
authentication.requestToken.restore();
|
||||
authentication.logout.restore();
|
||||
});
|
||||
|
||||
it('must be till 2100 to test with validToken', () =>
|
||||
expect(new Date().getFullYear(), 'to be less than', 2100)
|
||||
);
|
||||
|
||||
describe('#before', () => {
|
||||
it('should request new token', () => {
|
||||
getState.returns({
|
||||
user: {
|
||||
describe('when token expired', () => {
|
||||
beforeEach(() => {
|
||||
const account = {
|
||||
token: expiredToken,
|
||||
refreshToken
|
||||
}
|
||||
};
|
||||
getState.returns({
|
||||
accounts: {
|
||||
active: account,
|
||||
available: [account]
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
});
|
||||
|
||||
const data = {
|
||||
url: 'foo',
|
||||
options: {
|
||||
headers: {}
|
||||
}
|
||||
};
|
||||
it('should request new token', () => {
|
||||
const data = {
|
||||
url: 'foo',
|
||||
options: {
|
||||
headers: {}
|
||||
}
|
||||
};
|
||||
|
||||
authentication.requestToken.returns(Promise.resolve({token: validToken}));
|
||||
authentication.requestToken.returns(Promise.resolve({token: validToken}));
|
||||
|
||||
return middleware.before(data).then((resp) => {
|
||||
expect(resp, 'to satisfy', data);
|
||||
|
||||
expect(authentication.requestToken, 'to have a call satisfying', [
|
||||
refreshToken
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not apply to refresh-token request', () => {
|
||||
const data = {url: '/refresh-token', options: {}};
|
||||
const resp = middleware.before(data);
|
||||
|
||||
return middleware.before(data).then((resp) => {
|
||||
expect(resp, 'to satisfy', data);
|
||||
|
||||
expect(authentication.requestToken, 'to have a call satisfying', [
|
||||
expect(authentication.requestToken, 'was not called');
|
||||
});
|
||||
|
||||
it('should not auto refresh token if options.token specified', () => {
|
||||
const data = {
|
||||
url: 'foo',
|
||||
options: {token: 'foo'}
|
||||
};
|
||||
middleware.before(data);
|
||||
|
||||
expect(authentication.requestToken, 'was not called');
|
||||
});
|
||||
|
||||
it('should update user with new token', () => {
|
||||
const data = {
|
||||
url: 'foo',
|
||||
options: {
|
||||
headers: {}
|
||||
}
|
||||
};
|
||||
|
||||
authentication.requestToken.returns(Promise.resolve({token: validToken}));
|
||||
|
||||
return middleware.before(data).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
updateToken(validToken)
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should if token can not be parsed', () => {
|
||||
const account = {
|
||||
token: 'realy bad token',
|
||||
refreshToken
|
||||
]);
|
||||
};
|
||||
getState.returns({
|
||||
accounts: {
|
||||
active: account,
|
||||
available: [account]
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
|
||||
const req = {url: 'foo', options: {}};
|
||||
|
||||
return expect(middleware.before(req), 'to be fulfilled with', req).then(() => {
|
||||
expect(authentication.requestToken, 'was not called');
|
||||
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
{payload: {isGuest: true}}
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should logout if token request failed', () => {
|
||||
authentication.requestToken.returns(Promise.reject());
|
||||
|
||||
return expect(middleware.before({url: 'foo', options: {}}), 'to be fulfilled').then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
{payload: {isGuest: true}}
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when token expired legacy user', () => {
|
||||
beforeEach(() => {
|
||||
getState.returns({
|
||||
accounts: {
|
||||
active: null,
|
||||
available: []
|
||||
},
|
||||
user: {
|
||||
token: expiredToken,
|
||||
refreshToken
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should request new token', () => {
|
||||
const data = {
|
||||
url: 'foo',
|
||||
options: {
|
||||
headers: {}
|
||||
}
|
||||
};
|
||||
|
||||
authentication.requestToken.returns(Promise.resolve({token: validToken}));
|
||||
|
||||
return middleware.before(data).then((resp) => {
|
||||
expect(resp, 'to satisfy', data);
|
||||
|
||||
expect(authentication.requestToken, 'to have a call satisfying', [
|
||||
refreshToken
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should not be applied if no token', () => {
|
||||
getState.returns({
|
||||
accounts: {
|
||||
active: null
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
|
||||
@ -66,75 +191,124 @@ describe('refreshTokenMiddleware', () => {
|
||||
|
||||
expect(authentication.requestToken, 'was not called');
|
||||
});
|
||||
|
||||
it('should not apply to refresh-token request', () => {
|
||||
getState.returns({
|
||||
user: {}
|
||||
});
|
||||
|
||||
const data = {url: '/refresh-token'};
|
||||
const resp = middleware.before(data);
|
||||
|
||||
expect(resp, 'to satisfy', data);
|
||||
|
||||
expect(authentication.requestToken, 'was not called');
|
||||
});
|
||||
|
||||
xit('should update user with new token'); // TODO: need a way to test, that action was called
|
||||
xit('should logout if invalid token'); // TODO: need a way to test, that action was called
|
||||
|
||||
xit('should logout if token request failed', () => {
|
||||
getState.returns({
|
||||
user: {
|
||||
token: expiredToken,
|
||||
refreshToken
|
||||
}
|
||||
});
|
||||
|
||||
authentication.requestToken.returns(Promise.reject());
|
||||
|
||||
return middleware.before({url: 'foo'}).then((resp) => {
|
||||
// TODO: need a way to test, that action was called
|
||||
expect(dispatch, 'to have a call satisfying', logout);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#catch', () => {
|
||||
it('should request new token', () => {
|
||||
const expiredResponse = {
|
||||
name: 'Unauthorized',
|
||||
message: 'Token expired',
|
||||
code: 0,
|
||||
status: 401,
|
||||
type: 'yii\\web\\UnauthorizedHttpException'
|
||||
};
|
||||
|
||||
const badTokenReponse = {
|
||||
name: 'Unauthorized',
|
||||
message: 'You are requesting with an invalid credential.',
|
||||
code: 0,
|
||||
status: 401,
|
||||
type: 'yii\\web\\UnauthorizedHttpException'
|
||||
};
|
||||
|
||||
const incorrectTokenReponse = {
|
||||
name: 'Unauthorized',
|
||||
message: 'Incorrect token',
|
||||
code: 0,
|
||||
status: 401,
|
||||
type: 'yii\\web\\UnauthorizedHttpException'
|
||||
};
|
||||
|
||||
let restart;
|
||||
|
||||
beforeEach(() => {
|
||||
getState.returns({
|
||||
user: {
|
||||
refreshToken
|
||||
}
|
||||
accounts: {
|
||||
active: {refreshToken},
|
||||
available: [{refreshToken}]
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
|
||||
const restart = sinon.stub().named('restart');
|
||||
restart = sinon.stub().named('restart');
|
||||
|
||||
authentication.requestToken.returns(Promise.resolve({token: validToken}));
|
||||
});
|
||||
|
||||
return middleware.catch({
|
||||
status: 401,
|
||||
message: 'Token expired'
|
||||
}, restart).then(() => {
|
||||
it('should request new token if expired', () =>
|
||||
middleware.catch(expiredResponse, {options: {}}, restart).then(() => {
|
||||
expect(authentication.requestToken, 'to have a call satisfying', [
|
||||
refreshToken
|
||||
]);
|
||||
expect(restart, 'was called');
|
||||
})
|
||||
);
|
||||
|
||||
it('should logout user if invalid credential', () =>
|
||||
expect(
|
||||
middleware.catch(badTokenReponse, {options: {}}, restart),
|
||||
'to be rejected'
|
||||
).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
{payload: {isGuest: true}}
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
it('should logout user if token is incorrect', () =>
|
||||
expect(
|
||||
middleware.catch(incorrectTokenReponse, {options: {}}, restart),
|
||||
'to be rejected'
|
||||
).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
{payload: {isGuest: true}}
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
it('should pass the request through if options.token specified', () => {
|
||||
const promise = middleware.catch(expiredResponse, {
|
||||
options: {
|
||||
token: 'foo'
|
||||
}
|
||||
}, restart);
|
||||
|
||||
return expect(promise, 'to be rejected with', expiredResponse).then(() => {
|
||||
expect(restart, 'was not called');
|
||||
expect(authentication.requestToken, 'was not called');
|
||||
});
|
||||
});
|
||||
|
||||
xit('should logout user if token cannot be refreshed'); // TODO: need a way to test, that action was called
|
||||
|
||||
it('should pass the rest of failed requests through', () => {
|
||||
const resp = {};
|
||||
|
||||
const promise = middleware.catch(resp);
|
||||
const promise = middleware.catch(resp, {
|
||||
options: {}
|
||||
}, restart);
|
||||
|
||||
expect(promise, 'to be rejected');
|
||||
|
||||
return promise.catch((actual) => {
|
||||
expect(actual, 'to be', resp);
|
||||
return expect(promise, 'to be rejected with', resp).then(() => {
|
||||
expect(restart, 'was not called');
|
||||
expect(authentication.requestToken, 'was not called');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legacy user.refreshToken', () => {
|
||||
beforeEach(() => {
|
||||
getState.returns({
|
||||
accounts: {
|
||||
active: null
|
||||
},
|
||||
user: {refreshToken}
|
||||
});
|
||||
});
|
||||
|
||||
it('should request new token if expired', () =>
|
||||
middleware.catch(expiredResponse, {options: {}}, restart).then(() => {
|
||||
expect(authentication.requestToken, 'to have a call satisfying', [
|
||||
refreshToken
|
||||
]);
|
||||
expect(restart, 'was called');
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
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 = {
|
||||
isGuest: true
|
||||
};
|
||||
state.auth = {
|
||||
login: null
|
||||
};
|
||||
});
|
||||
|
||||
it('should redirect guest / -> /login', () => {
|
||||
@ -81,7 +84,8 @@ describe('AuthFlow.functional', () => {
|
||||
|
||||
auth: {
|
||||
oauth: {
|
||||
clientId: 123
|
||||
clientId: 123,
|
||||
prompt: []
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -267,6 +267,7 @@ describe('AuthFlow', () => {
|
||||
'/password': LoginState,
|
||||
'/accept-rules': LoginState,
|
||||
'/oauth/permissions': LoginState,
|
||||
'/oauth/choose-account': LoginState,
|
||||
'/oauth/finish': LoginState,
|
||||
'/oauth2/v1/foo': OAuthState,
|
||||
'/oauth2/v1': OAuthState,
|
||||
|
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: {
|
||||
oauth: {
|
||||
clientId: 'ely.by'
|
||||
clientId: 'ely.by',
|
||||
prompt: []
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -166,7 +167,8 @@ describe('CompleteState', () => {
|
||||
},
|
||||
auth: {
|
||||
oauth: {
|
||||
clientId: 'ely.by'
|
||||
clientId: 'ely.by',
|
||||
prompt: []
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -194,7 +196,8 @@ describe('CompleteState', () => {
|
||||
},
|
||||
auth: {
|
||||
oauth: {
|
||||
clientId: 'ely.by'
|
||||
clientId: 'ely.by',
|
||||
prompt: []
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -225,7 +228,8 @@ describe('CompleteState', () => {
|
||||
},
|
||||
auth: {
|
||||
oauth: {
|
||||
clientId: 'ely.by'
|
||||
clientId: 'ely.by',
|
||||
prompt: []
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -242,21 +246,21 @@ describe('CompleteState', () => {
|
||||
return promise.catch(mock.verify.bind(mock));
|
||||
};
|
||||
|
||||
it('should transition to finish state if rejected with static_page', () => {
|
||||
return testOAuth('resolve', {redirectUri: 'static_page'}, FinishState);
|
||||
});
|
||||
it('should transition to finish state if rejected with static_page', () =>
|
||||
testOAuth('resolve', {redirectUri: 'static_page'}, FinishState)
|
||||
);
|
||||
|
||||
it('should transition to finish state if rejected with static_page_with_code', () => {
|
||||
return testOAuth('resolve', {redirectUri: 'static_page_with_code'}, FinishState);
|
||||
});
|
||||
it('should transition to finish state if rejected with static_page_with_code', () =>
|
||||
testOAuth('resolve', {redirectUri: 'static_page_with_code'}, FinishState)
|
||||
);
|
||||
|
||||
it('should transition to login state if rejected with unauthorized', () => {
|
||||
return testOAuth('reject', {unauthorized: true}, LoginState);
|
||||
});
|
||||
it('should transition to login state if rejected with unauthorized', () =>
|
||||
testOAuth('reject', {unauthorized: true}, LoginState)
|
||||
);
|
||||
|
||||
it('should transition to permissions state if rejected with acceptRequired', () => {
|
||||
return testOAuth('reject', {acceptRequired: true}, PermissionsState);
|
||||
});
|
||||
it('should transition to permissions state if rejected with acceptRequired', () =>
|
||||
testOAuth('reject', {acceptRequired: true}, PermissionsState)
|
||||
);
|
||||
});
|
||||
|
||||
describe('permissions accept', () => {
|
||||
@ -285,7 +289,8 @@ describe('CompleteState', () => {
|
||||
},
|
||||
auth: {
|
||||
oauth: {
|
||||
clientId: 'ely.by'
|
||||
clientId: 'ely.by',
|
||||
prompt: []
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -309,7 +314,8 @@ describe('CompleteState', () => {
|
||||
},
|
||||
auth: {
|
||||
oauth: {
|
||||
clientId: 'ely.by'
|
||||
clientId: 'ely.by',
|
||||
prompt: []
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -337,6 +343,7 @@ describe('CompleteState', () => {
|
||||
auth: {
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
prompt: [],
|
||||
acceptRequired: true
|
||||
}
|
||||
}
|
||||
@ -365,6 +372,7 @@ describe('CompleteState', () => {
|
||||
auth: {
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
prompt: [],
|
||||
acceptRequired: true
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import LoginState from 'services/authFlow/LoginState';
|
||||
import PasswordState from 'services/authFlow/PasswordState';
|
||||
import ForgotPasswordState from 'services/authFlow/ForgotPasswordState';
|
||||
|
||||
import { bootstrap, expectState, expectNavigate, expectRun } from './helpers';
|
||||
|
||||
@ -24,7 +23,8 @@ describe('LoginState', () => {
|
||||
describe('#enter', () => {
|
||||
it('should navigate to /login', () => {
|
||||
context.getState.returns({
|
||||
user: {isGuest: true}
|
||||
user: {isGuest: true},
|
||||
auth: {login: null}
|
||||
});
|
||||
|
||||
expectNavigate(mock, '/login');
|
||||
@ -32,22 +32,15 @@ describe('LoginState', () => {
|
||||
state.enter(context);
|
||||
});
|
||||
|
||||
const testTransitionToPassword = (user) => {
|
||||
it('should transition to password if login was set', () => {
|
||||
context.getState.returns({
|
||||
user: user
|
||||
user: {isGuest: true},
|
||||
auth: {login: 'foo'}
|
||||
});
|
||||
|
||||
expectState(mock, PasswordState);
|
||||
|
||||
state.enter(context);
|
||||
};
|
||||
|
||||
it('should transition to password if has email', () => {
|
||||
testTransitionToPassword({email: 'foo'});
|
||||
});
|
||||
|
||||
it('should transition to password if has username', () => {
|
||||
testTransitionToPassword({username: 'foo'});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -28,6 +28,8 @@ describe('OAuthState', () => {
|
||||
response_type: 'response_type',
|
||||
description: 'description',
|
||||
scope: 'scope',
|
||||
prompt: 'none',
|
||||
login_hint: 1,
|
||||
state: 'state'
|
||||
};
|
||||
|
||||
@ -42,6 +44,8 @@ describe('OAuthState', () => {
|
||||
responseType: query.response_type,
|
||||
description: query.description,
|
||||
scope: query.scope,
|
||||
prompt: query.prompt,
|
||||
loginHint: query.login_hint,
|
||||
state: query.state
|
||||
})
|
||||
).returns({then() {}});
|
||||
|
@ -25,7 +25,8 @@ describe('PasswordState', () => {
|
||||
describe('#enter', () => {
|
||||
it('should navigate to /password', () => {
|
||||
context.getState.returns({
|
||||
user: {isGuest: true}
|
||||
user: {isGuest: true},
|
||||
auth: {login: 'foo'}
|
||||
});
|
||||
|
||||
expectNavigate(mock, '/password');
|
||||
@ -35,7 +36,8 @@ describe('PasswordState', () => {
|
||||
|
||||
it('should transition to complete if not guest', () => {
|
||||
context.getState.returns({
|
||||
user: {isGuest: false}
|
||||
user: {isGuest: false},
|
||||
auth: {login: null}
|
||||
});
|
||||
|
||||
expectState(mock, CompleteState);
|
||||
@ -45,42 +47,29 @@ describe('PasswordState', () => {
|
||||
});
|
||||
|
||||
describe('#resolve', () => {
|
||||
(function() {
|
||||
const expectedLogin = 'login';
|
||||
const expectedPassword = 'password';
|
||||
it('should call login with login and password', () => {
|
||||
const expectedLogin = 'foo';
|
||||
const expectedPassword = 'bar';
|
||||
const expectedRememberMe = true;
|
||||
|
||||
const testWith = (user) => {
|
||||
it(`should call login with email or username and password. User: ${JSON.stringify(user)}`, () => {
|
||||
context.getState.returns({user});
|
||||
|
||||
expectRun(
|
||||
mock,
|
||||
'login',
|
||||
sinon.match({
|
||||
login: expectedLogin,
|
||||
password: expectedPassword,
|
||||
rememberMe: expectedRememberMe,
|
||||
})
|
||||
).returns({then() {}});
|
||||
|
||||
state.resolve(context, {password: expectedPassword, rememberMe: expectedRememberMe});
|
||||
});
|
||||
};
|
||||
|
||||
testWith({
|
||||
email: expectedLogin
|
||||
context.getState.returns({
|
||||
auth: {
|
||||
login: expectedLogin
|
||||
}
|
||||
});
|
||||
|
||||
testWith({
|
||||
username: expectedLogin
|
||||
});
|
||||
expectRun(
|
||||
mock,
|
||||
'login',
|
||||
sinon.match({
|
||||
login: expectedLogin,
|
||||
password: expectedPassword,
|
||||
rememberMe: expectedRememberMe,
|
||||
})
|
||||
).returns({then() {}});
|
||||
|
||||
testWith({
|
||||
email: expectedLogin,
|
||||
username: expectedLogin
|
||||
});
|
||||
}());
|
||||
state.resolve(context, {password: expectedPassword, rememberMe: expectedRememberMe});
|
||||
});
|
||||
|
||||
it('should transition to complete state on successfull login', () => {
|
||||
const promise = Promise.resolve();
|
||||
@ -88,8 +77,8 @@ describe('PasswordState', () => {
|
||||
const expectedPassword = 'password';
|
||||
|
||||
context.getState.returns({
|
||||
user: {
|
||||
email: expectedLogin
|
||||
auth: {
|
||||
login: expectedLogin
|
||||
}
|
||||
});
|
||||
|
||||
@ -111,8 +100,8 @@ describe('PasswordState', () => {
|
||||
});
|
||||
|
||||
describe('#goBack', () => {
|
||||
it('should transition to forgot password state', () => {
|
||||
expectRun(mock, 'logout');
|
||||
it('should transition to login state', () => {
|
||||
expectRun(mock, 'setLogin', null);
|
||||
expectState(mock, LoginState);
|
||||
|
||||
state.goBack(context);
|
||||
|
@ -6,6 +6,7 @@ const webpack = require('webpack');
|
||||
const loaderUtils = require('loader-utils');
|
||||
const ExtractTextPlugin = require('extract-text-webpack-plugin');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const CircularDependencyPlugin = require('circular-dependency-plugin');
|
||||
const cssUrl = require('webpack-utils/cssUrl');
|
||||
const cssImport = require('postcss-import');
|
||||
|
||||
@ -247,7 +248,11 @@ if (isProduction) {
|
||||
if (!isProduction && !isTest) {
|
||||
webpackConfig.plugins.push(
|
||||
new webpack.HotModuleReplacementPlugin(),
|
||||
new webpack.NoErrorsPlugin()
|
||||
new webpack.NoErrorsPlugin(),
|
||||
new CircularDependencyPlugin({
|
||||
exclude: /node_modules/,
|
||||
failOnError: false
|
||||
})
|
||||
);
|
||||
|
||||
if (config.apiHost) {
|
||||
|
Loading…
Reference in New Issue
Block a user