mirror of
https://github.com/elyby/accounts-frontend.git
synced 2024-09-29 23:07:31 +05:30
#305: add mfa step during auth
This commit is contained in:
parent
d0a356050f
commit
5a16fe26ae
1
.eslintignore
Normal file
1
.eslintignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
flow-typed
|
@ -213,7 +213,7 @@
|
|||||||
"react/no-direct-mutation-state": "warn",
|
"react/no-direct-mutation-state": "warn",
|
||||||
"react/require-render-return": "warn",
|
"react/require-render-return": "warn",
|
||||||
"react/no-is-mounted": "warn",
|
"react/no-is-mounted": "warn",
|
||||||
"react/no-multi-comp": "warn",
|
"react/no-multi-comp": "off",
|
||||||
"react/no-string-refs": "warn",
|
"react/no-string-refs": "warn",
|
||||||
"react/no-unknown-property": "warn",
|
"react/no-unknown-property": "warn",
|
||||||
"react/prefer-es6-class": "warn",
|
"react/prefer-es6-class": "warn",
|
||||||
|
@ -4,6 +4,7 @@
|
|||||||
[include]
|
[include]
|
||||||
|
|
||||||
[libs]
|
[libs]
|
||||||
|
./flow-typed
|
||||||
|
|
||||||
[options]
|
[options]
|
||||||
module.system.node.resolve_dirname=node_modules
|
module.system.node.resolve_dirname=node_modules
|
||||||
|
29
flow-typed/Promise.js
vendored
Normal file
29
flow-typed/Promise.js
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* This is a copypasted declaration from
|
||||||
|
* https://github.com/facebook/flow/blob/master/lib/core.js
|
||||||
|
* with addition of finally method
|
||||||
|
*/
|
||||||
|
declare class Promise<+R> {
|
||||||
|
constructor(callback: (
|
||||||
|
resolve: (result: Promise<R> | R) => void,
|
||||||
|
reject: (error: any) => void
|
||||||
|
) => mixed): void;
|
||||||
|
|
||||||
|
then<U>(
|
||||||
|
onFulfill?: (value: R) => Promise<U> | U,
|
||||||
|
onReject?: (error: any) => Promise<U> | U
|
||||||
|
): Promise<U>;
|
||||||
|
|
||||||
|
catch<U>(
|
||||||
|
onReject?: (error: any) => Promise<U> | U
|
||||||
|
): Promise<R | U>;
|
||||||
|
|
||||||
|
static resolve<T>(object: Promise<T> | T): Promise<T>;
|
||||||
|
static reject<T>(error?: any): Promise<T>;
|
||||||
|
static all<Elem, T:Iterable<Elem>>(promises: T): Promise<$TupleMap<T, typeof $await>>;
|
||||||
|
static race<T, Elem: Promise<T> | T>(promises: Array<Elem>): Promise<T>;
|
||||||
|
|
||||||
|
finally<T>(
|
||||||
|
onSettled?: ?(value: any) => Promise<T> | T
|
||||||
|
): Promise<T>;
|
||||||
|
}
|
@ -1,9 +1,11 @@
|
|||||||
import React, { Component, PropTypes } from 'react';
|
import React, { Component } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { TransitionMotion, spring } from 'react-motion';
|
import { TransitionMotion, spring } from 'react-motion';
|
||||||
|
|
||||||
import { Panel, PanelBody, PanelFooter, PanelHeader } from 'components/ui/Panel';
|
import { Panel, PanelBody, PanelFooter, PanelHeader } from 'components/ui/Panel';
|
||||||
|
import { getLogin } from 'components/auth/reducer';
|
||||||
import { Form } from 'components/ui/form';
|
import { Form } from 'components/ui/form';
|
||||||
import MeasureHeight from 'components/MeasureHeight';
|
import MeasureHeight from 'components/MeasureHeight';
|
||||||
import { helpLinks as helpLinksStyles } from 'components/auth/helpLinks.scss';
|
import { helpLinks as helpLinksStyles } from 'components/auth/helpLinks.scss';
|
||||||
@ -30,7 +32,7 @@ const changeContextSpringConfig = {stiffness: 500, damping: 20, precision: 0.5};
|
|||||||
* (e.g. the panel with lower index will slide from left side, and with greater from right side)
|
* (e.g. the panel with lower index will slide from left side, and with greater from right side)
|
||||||
*/
|
*/
|
||||||
const contexts = [
|
const contexts = [
|
||||||
['login', 'password', 'forgotPassword', 'recoverPassword'],
|
['login', 'password', 'mfa', 'forgotPassword', 'recoverPassword'],
|
||||||
['register', 'activation', 'resendActivation'],
|
['register', 'activation', 'resendActivation'],
|
||||||
['acceptRules'],
|
['acceptRules'],
|
||||||
['chooseAccount', 'permissions']
|
['chooseAccount', 'permissions']
|
||||||
@ -459,7 +461,7 @@ class PanelTransition extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default connect((state) => {
|
export default connect((state) => {
|
||||||
const {login} = state.auth;
|
const login = getLogin(state);
|
||||||
let user = {
|
let user = {
|
||||||
...state.user
|
...state.user
|
||||||
};
|
};
|
||||||
|
@ -4,14 +4,10 @@ To add new panel you need to:
|
|||||||
|
|
||||||
* create panel component at `components/auth/[panelId]`
|
* create panel component at `components/auth/[panelId]`
|
||||||
* add new context in `components/auth/PanelTransition`
|
* add new context in `components/auth/PanelTransition`
|
||||||
* connect component to `routes`
|
* connect component to router in `pages/auth/AuthPage`
|
||||||
* add new state to `services/authFlow` and coresponding test to `tests/services/authFlow`
|
* 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)
|
* 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`
|
* add new actions to `components/auth/actions` and api endpoints to `services/api`
|
||||||
* whatever else you need
|
* whatever else you need
|
||||||
|
|
||||||
Commit id with example: f4d315c
|
Commit id with example implementation: f4d315c
|
||||||
|
|
||||||
# TODO
|
|
||||||
|
|
||||||
This flow must be simplified
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
// @flow
|
||||||
import { browserHistory } from 'services/history';
|
import { browserHistory } from 'services/history';
|
||||||
|
|
||||||
import logger from 'services/logger';
|
import logger from 'services/logger';
|
||||||
@ -23,7 +24,7 @@ export { authenticate, logoutAll as logout } from 'components/accounts/actions';
|
|||||||
*
|
*
|
||||||
* @return {object} - action definition
|
* @return {object} - action definition
|
||||||
*/
|
*/
|
||||||
export function goBack(fallbackUrl = null) {
|
export function goBack(fallbackUrl?: ?string = null) {
|
||||||
if (history.canGoBack()) {
|
if (history.canGoBack()) {
|
||||||
browserHistory.goBack();
|
browserHistory.goBack();
|
||||||
} else if (fallbackUrl) {
|
} else if (fallbackUrl) {
|
||||||
@ -35,7 +36,7 @@ export function goBack(fallbackUrl = null) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function redirect(url) {
|
export function redirect(url: string) {
|
||||||
loader.show();
|
loader.show();
|
||||||
|
|
||||||
return () => new Promise(() => {
|
return () => new Promise(() => {
|
||||||
@ -45,14 +46,25 @@ export function redirect(url) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function login({login = '', password = '', rememberMe = false}) {
|
export function login({
|
||||||
|
login = '',
|
||||||
|
password = '',
|
||||||
|
totp,
|
||||||
|
rememberMe = false
|
||||||
|
}: {
|
||||||
|
login: string,
|
||||||
|
password?: string,
|
||||||
|
totp?: string,
|
||||||
|
rememberMe?: bool
|
||||||
|
}) {
|
||||||
const PASSWORD_REQUIRED = 'error.password_required';
|
const PASSWORD_REQUIRED = 'error.password_required';
|
||||||
const LOGIN_REQUIRED = 'error.login_required';
|
const LOGIN_REQUIRED = 'error.login_required';
|
||||||
const ACTIVATION_REQUIRED = 'error.account_not_activated';
|
const ACTIVATION_REQUIRED = 'error.account_not_activated';
|
||||||
|
const TOTP_REQUIRED = 'error.totp_required';
|
||||||
|
|
||||||
return wrapInLoader((dispatch) =>
|
return wrapInLoader((dispatch) =>
|
||||||
authentication.login(
|
authentication.login(
|
||||||
{login, password, rememberMe}
|
{login, password, totp, rememberMe}
|
||||||
)
|
)
|
||||||
.then(authHandler(dispatch))
|
.then(authHandler(dispatch))
|
||||||
.catch((resp) => {
|
.catch((resp) => {
|
||||||
@ -61,6 +73,12 @@ export function login({login = '', password = '', rememberMe = false}) {
|
|||||||
return dispatch(setLogin(login));
|
return dispatch(setLogin(login));
|
||||||
} else if (resp.errors.login === ACTIVATION_REQUIRED) {
|
} else if (resp.errors.login === ACTIVATION_REQUIRED) {
|
||||||
return dispatch(needActivation());
|
return dispatch(needActivation());
|
||||||
|
} else if (resp.errors.totp === TOTP_REQUIRED) {
|
||||||
|
return dispatch(requestTotp({
|
||||||
|
login,
|
||||||
|
password,
|
||||||
|
rememberMe
|
||||||
|
}));
|
||||||
} else if (resp.errors.login === LOGIN_REQUIRED && password) {
|
} else if (resp.errors.login === LOGIN_REQUIRED && password) {
|
||||||
logger.warn('No login on password panel');
|
logger.warn('No login on password panel');
|
||||||
|
|
||||||
@ -83,6 +101,9 @@ export function acceptRules() {
|
|||||||
export function forgotPassword({
|
export function forgotPassword({
|
||||||
login = '',
|
login = '',
|
||||||
captcha = ''
|
captcha = ''
|
||||||
|
}: {
|
||||||
|
login: string,
|
||||||
|
captcha: string
|
||||||
}) {
|
}) {
|
||||||
return wrapInLoader((dispatch, getState) =>
|
return wrapInLoader((dispatch, getState) =>
|
||||||
authentication.forgotPassword({login, captcha})
|
authentication.forgotPassword({login, captcha})
|
||||||
@ -97,6 +118,10 @@ export function recoverPassword({
|
|||||||
key = '',
|
key = '',
|
||||||
newPassword = '',
|
newPassword = '',
|
||||||
newRePassword = ''
|
newRePassword = ''
|
||||||
|
}: {
|
||||||
|
key: string,
|
||||||
|
newPassword: string,
|
||||||
|
newRePassword: string
|
||||||
}) {
|
}) {
|
||||||
return wrapInLoader((dispatch) =>
|
return wrapInLoader((dispatch) =>
|
||||||
authentication.recoverPassword({key, newPassword, newRePassword})
|
authentication.recoverPassword({key, newPassword, newRePassword})
|
||||||
@ -112,6 +137,13 @@ export function register({
|
|||||||
rePassword = '',
|
rePassword = '',
|
||||||
captcha = '',
|
captcha = '',
|
||||||
rulesAgreement = false
|
rulesAgreement = false
|
||||||
|
}: {
|
||||||
|
email: string,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
rePassword: string,
|
||||||
|
captcha: string,
|
||||||
|
rulesAgreement: bool
|
||||||
}) {
|
}) {
|
||||||
return wrapInLoader((dispatch, getState) =>
|
return wrapInLoader((dispatch, getState) =>
|
||||||
signup.register({
|
signup.register({
|
||||||
@ -134,7 +166,7 @@ export function register({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activate({key = ''}) {
|
export function activate({key = ''}: {key: string}) {
|
||||||
return wrapInLoader((dispatch) =>
|
return wrapInLoader((dispatch) =>
|
||||||
signup.activate({key})
|
signup.activate({key})
|
||||||
.then(authHandler(dispatch))
|
.then(authHandler(dispatch))
|
||||||
@ -142,7 +174,13 @@ export function activate({key = ''}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resendActivation({email = '', captcha}) {
|
export function resendActivation({
|
||||||
|
email = '',
|
||||||
|
captcha
|
||||||
|
}: {
|
||||||
|
email: string,
|
||||||
|
captcha: string
|
||||||
|
}) {
|
||||||
return wrapInLoader((dispatch) =>
|
return wrapInLoader((dispatch) =>
|
||||||
signup.resendActivation({email, captcha})
|
signup.resendActivation({email, captcha})
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
@ -160,16 +198,43 @@ export function contactUs() {
|
|||||||
return createPopup(ContactForm);
|
return createPopup(ContactForm);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SET_LOGIN = 'auth:setLogin';
|
export const SET_CREDENTIALS = 'auth:setCredentials';
|
||||||
export function setLogin(login) {
|
/**
|
||||||
|
* Sets login in credentials state
|
||||||
|
*
|
||||||
|
* Resets the state, when `null` is passed
|
||||||
|
*
|
||||||
|
* @param {string|null} login
|
||||||
|
*
|
||||||
|
* @return {object}
|
||||||
|
*/
|
||||||
|
export function setLogin(login: ?string) {
|
||||||
return {
|
return {
|
||||||
type: SET_LOGIN,
|
type: SET_CREDENTIALS,
|
||||||
payload: login
|
payload: login ? {
|
||||||
|
login
|
||||||
|
} : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestTotp({login, password, rememberMe}: {
|
||||||
|
login: string,
|
||||||
|
password: string,
|
||||||
|
rememberMe: bool
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
type: SET_CREDENTIALS,
|
||||||
|
payload: {
|
||||||
|
login,
|
||||||
|
password,
|
||||||
|
rememberMe,
|
||||||
|
isTotpRequired: true
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SET_SWITCHER = 'auth:setAccountSwitcher';
|
export const SET_SWITCHER = 'auth:setAccountSwitcher';
|
||||||
export function setAccountSwitcher(isOn) {
|
export function setAccountSwitcher(isOn: bool) {
|
||||||
return {
|
return {
|
||||||
type: SET_SWITCHER,
|
type: SET_SWITCHER,
|
||||||
payload: isOn
|
payload: isOn
|
||||||
@ -177,7 +242,7 @@ export function setAccountSwitcher(isOn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ERROR = 'auth:error';
|
export const ERROR = 'auth:error';
|
||||||
export function setErrors(errors) {
|
export function setErrors(errors: ?{[key: string]: string}) {
|
||||||
return {
|
return {
|
||||||
type: ERROR,
|
type: ERROR,
|
||||||
payload: errors,
|
payload: errors,
|
||||||
@ -213,7 +278,16 @@ const KNOWN_SCOPES = [
|
|||||||
*
|
*
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
export function oAuthValidate(oauthData) {
|
export function oAuthValidate(oauthData: {
|
||||||
|
clientId: string,
|
||||||
|
redirectUrl: string,
|
||||||
|
responseType: string,
|
||||||
|
description: string,
|
||||||
|
scope: string,
|
||||||
|
prompt: 'none'|'consent'|'select_account',
|
||||||
|
loginHint?: string,
|
||||||
|
state?: string
|
||||||
|
}) {
|
||||||
// TODO: move to oAuth actions?
|
// TODO: move to oAuth actions?
|
||||||
// test request: /oauth?client_id=ely&redirect_uri=http%3A%2F%2Fely.by&response_type=code&scope=minecraft_server_session&description=foo
|
// test request: /oauth?client_id=ely&redirect_uri=http%3A%2F%2Fely.by&response_type=code&scope=minecraft_server_session&description=foo
|
||||||
return wrapInLoader((dispatch) =>
|
return wrapInLoader((dispatch) =>
|
||||||
@ -255,7 +329,7 @@ export function oAuthValidate(oauthData) {
|
|||||||
*
|
*
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
export function oAuthComplete(params = {}) {
|
export function oAuthComplete(params: {accept?: bool} = {}) {
|
||||||
return wrapInLoader((dispatch, getState) =>
|
return wrapInLoader((dispatch, getState) =>
|
||||||
oauth.complete(getState().auth.oauth, params)
|
oauth.complete(getState().auth.oauth, params)
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
@ -297,7 +371,15 @@ function handleOauthParamsValidation(resp = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SET_CLIENT = 'set_client';
|
export const SET_CLIENT = 'set_client';
|
||||||
export function setClient({id, name, description}) {
|
export function setClient({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description
|
||||||
|
}: {
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
description: string
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
type: SET_CLIENT,
|
type: SET_CLIENT,
|
||||||
payload: {id, name, description}
|
payload: {id, name, description}
|
||||||
@ -305,7 +387,7 @@ export function setClient({id, name, description}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resetOAuth() {
|
export function resetOAuth() {
|
||||||
return (dispatch) => {
|
return (dispatch: (Function|Object) => void) => {
|
||||||
localStorage.removeItem('oauthData');
|
localStorage.removeItem('oauthData');
|
||||||
dispatch(setOAuthRequest({}));
|
dispatch(setOAuthRequest({}));
|
||||||
};
|
};
|
||||||
@ -317,14 +399,22 @@ export function resetOAuth() {
|
|||||||
* @return {function}
|
* @return {function}
|
||||||
*/
|
*/
|
||||||
export function resetAuth() {
|
export function resetAuth() {
|
||||||
return (dispatch) => {
|
return (dispatch: (Function|Object) => void) => {
|
||||||
dispatch(setLogin(null));
|
dispatch(setLogin(null));
|
||||||
dispatch(resetOAuth({}));
|
dispatch(resetOAuth());
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SET_OAUTH = 'set_oauth';
|
export const SET_OAUTH = 'set_oauth';
|
||||||
export function setOAuthRequest(oauth) {
|
export function setOAuthRequest(oauth: {
|
||||||
|
client_id?: string,
|
||||||
|
redirect_uri?: string,
|
||||||
|
response_type?: string,
|
||||||
|
scope?: string,
|
||||||
|
prompt?: string,
|
||||||
|
loginHint?: string,
|
||||||
|
state?: string
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
type: SET_OAUTH,
|
type: SET_OAUTH,
|
||||||
payload: {
|
payload: {
|
||||||
@ -340,7 +430,11 @@ export function setOAuthRequest(oauth) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SET_OAUTH_RESULT = 'set_oauth_result';
|
export const SET_OAUTH_RESULT = 'set_oauth_result';
|
||||||
export function setOAuthCode(oauth) {
|
export function setOAuthCode(oauth: {
|
||||||
|
success: bool,
|
||||||
|
code: string,
|
||||||
|
displayCode: bool
|
||||||
|
}) {
|
||||||
return {
|
return {
|
||||||
type: SET_OAUTH_RESULT,
|
type: SET_OAUTH_RESULT,
|
||||||
payload: {
|
payload: {
|
||||||
@ -359,7 +453,7 @@ export function requirePermissionsAccept() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SET_SCOPES = 'set_scopes';
|
export const SET_SCOPES = 'set_scopes';
|
||||||
export function setScopes(scopes) {
|
export function setScopes(scopes: Array<string>) {
|
||||||
if (!(scopes instanceof Array)) {
|
if (!(scopes instanceof Array)) {
|
||||||
throw new Error('Scopes must be array');
|
throw new Error('Scopes must be array');
|
||||||
}
|
}
|
||||||
@ -372,7 +466,7 @@ export function setScopes(scopes) {
|
|||||||
|
|
||||||
|
|
||||||
export const SET_LOADING_STATE = 'set_loading_state';
|
export const SET_LOADING_STATE = 'set_loading_state';
|
||||||
export function setLoadingState(isLoading) {
|
export function setLoadingState(isLoading: bool) {
|
||||||
return {
|
return {
|
||||||
type: SET_LOADING_STATE,
|
type: SET_LOADING_STATE,
|
||||||
payload: isLoading
|
payload: isLoading
|
||||||
@ -380,7 +474,7 @@ export function setLoadingState(isLoading) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function wrapInLoader(fn) {
|
function wrapInLoader(fn) {
|
||||||
return (dispatch, getState) => {
|
return (dispatch: (Function|Object) => void, getState: Object) => {
|
||||||
dispatch(setLoadingState(true));
|
dispatch(setLoadingState(true));
|
||||||
const endLoading = () => dispatch(setLoadingState(false));
|
const endLoading = () => dispatch(setLoadingState(false));
|
||||||
|
|
||||||
@ -414,14 +508,15 @@ function authHandler(dispatch) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function validationErrorsHandler(dispatch, repeatUrl) {
|
function validationErrorsHandler(dispatch: (Function|Object) => void, repeatUrl?: string) {
|
||||||
return (resp) => {
|
return (resp) => {
|
||||||
if (resp.errors) {
|
if (resp.errors) {
|
||||||
const firstError = Object.keys(resp.errors)[0];
|
const firstError = Object.keys(resp.errors)[0];
|
||||||
const error = {
|
const error = {
|
||||||
type: resp.errors[firstError],
|
type: resp.errors[firstError],
|
||||||
payload: {
|
payload: {
|
||||||
isGuest: true
|
isGuest: true,
|
||||||
|
repeatUrl: ''
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -432,9 +527,7 @@ function validationErrorsHandler(dispatch, repeatUrl) {
|
|||||||
|
|
||||||
if (['error.key_not_exists', 'error.key_expire'].includes(error.type) && repeatUrl) {
|
if (['error.key_not_exists', 'error.key_expire'].includes(error.type) && repeatUrl) {
|
||||||
// TODO: this should be formatted on backend
|
// TODO: this should be formatted on backend
|
||||||
Object.assign(error.payload, {
|
error.payload.repeatUrl = repeatUrl;
|
||||||
repeatUrl
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resp.errors[firstError] = error;
|
resp.errors[firstError] = error;
|
||||||
|
@ -3,7 +3,8 @@ import React from 'react';
|
|||||||
import { FormattedMessage as Message } from 'react-intl';
|
import { FormattedMessage as Message } from 'react-intl';
|
||||||
|
|
||||||
import { Input, Captcha } from 'components/ui/form';
|
import { Input, Captcha } from 'components/ui/form';
|
||||||
import icons from 'components/ui/icons.scss';
|
import { getLogin } from 'components/auth/reducer';
|
||||||
|
import { PanelIcon } from 'components/ui/Panel';
|
||||||
import BaseAuthBody from 'components/auth/BaseAuthBody';
|
import BaseAuthBody from 'components/auth/BaseAuthBody';
|
||||||
|
|
||||||
import styles from './forgotPassword.scss';
|
import styles from './forgotPassword.scss';
|
||||||
@ -28,9 +29,7 @@ export default class ForgotPasswordBody extends BaseAuthBody {
|
|||||||
<div>
|
<div>
|
||||||
{this.renderErrors()}
|
{this.renderErrors()}
|
||||||
|
|
||||||
<div className={styles.bigIcon}>
|
<PanelIcon icon="lock" />
|
||||||
<span className={icons.lock} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoginEditShown ? (
|
{isLoginEditShown ? (
|
||||||
<div>
|
<div>
|
||||||
@ -73,9 +72,10 @@ export default class ForgotPasswordBody extends BaseAuthBody {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getLogin() {
|
getLogin() {
|
||||||
const { user, auth } = this.context;
|
const login = getLogin(this.context);
|
||||||
|
const { user } = this.context;
|
||||||
|
|
||||||
return auth.login || user.username || user.email || '';
|
return login || user.username || user.email || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
onClickEdit = () => {
|
onClickEdit = () => {
|
||||||
|
@ -7,14 +7,6 @@
|
|||||||
color: #aaa;
|
color: #aaa;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: вынести иконки такого типа в какую-то внешнюю структуру?
|
|
||||||
.bigIcon {
|
|
||||||
color: #ccc;
|
|
||||||
font-size: 100px;
|
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.login {
|
.login {
|
||||||
composes: email from 'components/auth/password/password.scss';
|
composes: email from 'components/auth/password/password.scss';
|
||||||
}
|
}
|
||||||
|
4
src/components/auth/mfa/Mfa.intl.json
Normal file
4
src/components/auth/mfa/Mfa.intl.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"enterTotp": "Enter code",
|
||||||
|
"description": "In order to sign in this account, you need to enter a one-time password from mobile application"
|
||||||
|
}
|
15
src/components/auth/mfa/Mfa.js
Normal file
15
src/components/auth/mfa/Mfa.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
// @flow
|
||||||
|
import factory from 'components/auth/factory';
|
||||||
|
|
||||||
|
import Body from './MfaBody';
|
||||||
|
import messages from './Mfa.intl.json';
|
||||||
|
import passwordMessages from '../password/Password.intl.json';
|
||||||
|
|
||||||
|
export default factory({
|
||||||
|
title: messages.enterTotp,
|
||||||
|
body: Body,
|
||||||
|
footer: {
|
||||||
|
color: 'green',
|
||||||
|
label: passwordMessages.signInButton
|
||||||
|
}
|
||||||
|
});
|
39
src/components/auth/mfa/MfaBody.js
Normal file
39
src/components/auth/mfa/MfaBody.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
// @flow
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage as Message } from 'react-intl';
|
||||||
|
|
||||||
|
import { PanelIcon } from 'components/ui/Panel';
|
||||||
|
import { Input } from 'components/ui/form';
|
||||||
|
import BaseAuthBody from 'components/auth/BaseAuthBody';
|
||||||
|
|
||||||
|
import styles from './mfa.scss';
|
||||||
|
import messages from './Mfa.intl.json';
|
||||||
|
|
||||||
|
export default class MfaBody extends BaseAuthBody {
|
||||||
|
static panelId = 'mfa';
|
||||||
|
static hasGoBack = true;
|
||||||
|
|
||||||
|
autoFocusField = 'totp';
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{this.renderErrors()}
|
||||||
|
|
||||||
|
<PanelIcon icon="lock" />
|
||||||
|
|
||||||
|
<p className={styles.descriptionText}>
|
||||||
|
<Message {...messages.description} />
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Input {...this.bindField('totp')}
|
||||||
|
icon="key"
|
||||||
|
required
|
||||||
|
placeholder={messages.enterTotp}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
6
src/components/auth/mfa/mfa.scss
Normal file
6
src/components/auth/mfa/mfa.scss
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
.descriptionText {
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.4;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
color: #aaa;
|
||||||
|
}
|
@ -8,12 +8,12 @@ import {
|
|||||||
SET_SCOPES,
|
SET_SCOPES,
|
||||||
SET_LOADING_STATE,
|
SET_LOADING_STATE,
|
||||||
REQUIRE_PERMISSIONS_ACCEPT,
|
REQUIRE_PERMISSIONS_ACCEPT,
|
||||||
SET_LOGIN,
|
SET_CREDENTIALS,
|
||||||
SET_SWITCHER
|
SET_SWITCHER
|
||||||
} from './actions';
|
} from './actions';
|
||||||
|
|
||||||
export default combineReducers({
|
export default combineReducers({
|
||||||
login,
|
credentials,
|
||||||
error,
|
error,
|
||||||
isLoading,
|
isLoading,
|
||||||
isSwitcherEnabled,
|
isSwitcherEnabled,
|
||||||
@ -39,21 +39,31 @@ function error(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function login(
|
function credentials(
|
||||||
state = null,
|
state = {},
|
||||||
{type, payload = null}
|
{type, payload}: {
|
||||||
|
type: string,
|
||||||
|
payload: ?{
|
||||||
|
login?: string,
|
||||||
|
password?: string,
|
||||||
|
rememberMe?: bool,
|
||||||
|
isTotpRequired?: bool
|
||||||
|
}
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
switch (type) {
|
if (type === SET_CREDENTIALS) {
|
||||||
case SET_LOGIN:
|
if (payload
|
||||||
if (payload !== null && typeof payload !== 'string') {
|
&& typeof payload === 'object'
|
||||||
throw new Error('Expected payload with login string or null');
|
) {
|
||||||
|
return {
|
||||||
|
...payload
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
|
||||||
return state;
|
return state;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSwitcherEnabled(
|
function isSwitcherEnabled(
|
||||||
@ -150,3 +160,16 @@ function scopes(
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getLogin(state: Object): ?string {
|
||||||
|
return state.auth.credentials.login || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCredentials(state: Object): {
|
||||||
|
login?: string,
|
||||||
|
password?: string,
|
||||||
|
rememberMe?: bool,
|
||||||
|
isTotpRequired?: bool
|
||||||
|
} {
|
||||||
|
return state.auth.credentials;
|
||||||
|
}
|
||||||
|
@ -2,16 +2,16 @@ import expect from 'unexpected';
|
|||||||
|
|
||||||
import auth from 'components/auth/reducer';
|
import auth from 'components/auth/reducer';
|
||||||
import {
|
import {
|
||||||
setLogin, SET_LOGIN,
|
setLogin, SET_CREDENTIALS,
|
||||||
setAccountSwitcher, SET_SWITCHER
|
setAccountSwitcher, SET_SWITCHER
|
||||||
} from 'components/auth/actions';
|
} from 'components/auth/actions';
|
||||||
|
|
||||||
describe('components/auth/reducer', () => {
|
describe('components/auth/reducer', () => {
|
||||||
describe(SET_LOGIN, () => {
|
describe(SET_CREDENTIALS, () => {
|
||||||
it('should set login', () => {
|
it('should set login', () => {
|
||||||
const expectedLogin = 'foo';
|
const expectedLogin = 'foo';
|
||||||
|
|
||||||
expect(auth(undefined, setLogin(expectedLogin)), 'to satisfy', {
|
expect(auth(undefined, setLogin(expectedLogin)).credentials, 'to satisfy', {
|
||||||
login: expectedLogin
|
login: expectedLogin
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import React, { Component, PropTypes } from 'react';
|
// @flow
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
||||||
@ -7,8 +8,12 @@ import { omit } from 'functions';
|
|||||||
import styles from './panel.scss';
|
import styles from './panel.scss';
|
||||||
import icons from './icons.scss';
|
import icons from './icons.scss';
|
||||||
|
|
||||||
export function Panel(props) {
|
export function Panel(props: {
|
||||||
var { title, icon } = props;
|
title: string,
|
||||||
|
icon: string,
|
||||||
|
children: *
|
||||||
|
}) {
|
||||||
|
let { title, icon } = props;
|
||||||
|
|
||||||
if (icon) {
|
if (icon) {
|
||||||
icon = (
|
icon = (
|
||||||
@ -36,7 +41,9 @@ export function Panel(props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PanelHeader(props) {
|
export function PanelHeader(props: {
|
||||||
|
children: *
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.header} {...props}>
|
<div className={styles.header} {...props}>
|
||||||
{props.children}
|
{props.children}
|
||||||
@ -44,7 +51,9 @@ export function PanelHeader(props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PanelBody(props) {
|
export function PanelBody(props: {
|
||||||
|
children: *
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.body} {...props}>
|
<div className={styles.body} {...props}>
|
||||||
{props.children}
|
{props.children}
|
||||||
@ -52,7 +61,9 @@ export function PanelBody(props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PanelFooter(props) {
|
export function PanelFooter(props: {
|
||||||
|
children: *
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.footer} {...props}>
|
<div className={styles.footer} {...props}>
|
||||||
{props.children}
|
{props.children}
|
||||||
@ -61,11 +72,16 @@ export function PanelFooter(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PanelBodyHeader extends Component {
|
export class PanelBodyHeader extends Component {
|
||||||
static displayName = 'PanelBodyHeader';
|
props: {
|
||||||
|
type: 'default'|'error',
|
||||||
|
onClose: Function,
|
||||||
|
children: *
|
||||||
|
};
|
||||||
|
|
||||||
static propTypes = {
|
state: {
|
||||||
type: PropTypes.oneOf(['default', 'error']),
|
isClosed: bool
|
||||||
onClose: PropTypes.func
|
} = {
|
||||||
|
isClosed: false
|
||||||
};
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@ -79,18 +95,23 @@ export class PanelBodyHeader extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const className = classNames(styles[`${type}BodyHeader`], {
|
const className = classNames(styles[`${type}BodyHeader`], {
|
||||||
[styles.isClosed]: this.state && this.state.isClosed
|
[styles.isClosed]: this.state.isClosed
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const extraProps = omit(this.props, [
|
||||||
|
'type',
|
||||||
|
'onClose'
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className} {...omit(this.props, Object.keys(PanelBodyHeader.propTypes))}>
|
<div className={className} {...extraProps}>
|
||||||
{close}
|
{close}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onClose = (event) => {
|
onClose = (event: MouseEvent) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
this.setState({isClosed: true});
|
this.setState({isClosed: true});
|
||||||
@ -98,3 +119,11 @@ export class PanelBodyHeader extends Component {
|
|||||||
this.props.onClose();
|
this.props.onClose();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PanelIcon({icon}: {icon: string}) {
|
||||||
|
return (
|
||||||
|
<div className={styles.panelIcon}>
|
||||||
|
<span className={icons[icon]} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -129,3 +129,10 @@ $bodyTopBottomPadding: 15px;
|
|||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.panelIcon {
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 100px;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
@ -16,6 +16,7 @@ import Password from 'components/auth/password/Password';
|
|||||||
import AcceptRules from 'components/auth/acceptRules/AcceptRules';
|
import AcceptRules from 'components/auth/acceptRules/AcceptRules';
|
||||||
import ForgotPassword from 'components/auth/forgotPassword/ForgotPassword';
|
import ForgotPassword from 'components/auth/forgotPassword/ForgotPassword';
|
||||||
import RecoverPassword from 'components/auth/recoverPassword/RecoverPassword';
|
import RecoverPassword from 'components/auth/recoverPassword/RecoverPassword';
|
||||||
|
import Mfa from 'components/auth/mfa/Mfa';
|
||||||
import Finish from 'components/auth/finish/Finish';
|
import Finish from 'components/auth/finish/Finish';
|
||||||
|
|
||||||
import styles from './auth.scss';
|
import styles from './auth.scss';
|
||||||
@ -46,6 +47,7 @@ class AuthPage extends Component {
|
|||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/login" render={renderPanelTransition(Login)} />
|
<Route path="/login" render={renderPanelTransition(Login)} />
|
||||||
|
<Route path="/mfa" render={renderPanelTransition(Mfa)} />
|
||||||
<Route path="/password" render={renderPanelTransition(Password)} />
|
<Route path="/password" render={renderPanelTransition(Password)} />
|
||||||
<Route path="/register" render={renderPanelTransition(Register)} />
|
<Route path="/register" render={renderPanelTransition(Register)} />
|
||||||
<Route path="/activation/:key?" render={renderPanelTransition(Activation)} />
|
<Route path="/activation/:key?" render={renderPanelTransition(Activation)} />
|
||||||
@ -72,6 +74,7 @@ class AuthPage extends Component {
|
|||||||
|
|
||||||
function renderPanelTransition(factory) {
|
function renderPanelTransition(factory) {
|
||||||
const {Title, Body, Footer, Links} = factory();
|
const {Title, Body, Footer, Links} = factory();
|
||||||
|
|
||||||
return (props) => (
|
return (props) => (
|
||||||
<PanelTransition
|
<PanelTransition
|
||||||
key="panel-transition"
|
key="panel-transition"
|
||||||
|
@ -1,35 +1,54 @@
|
|||||||
|
// @flow
|
||||||
import request from 'services/request';
|
import request from 'services/request';
|
||||||
import accounts from 'services/api/accounts';
|
import accounts from 'services/api/accounts';
|
||||||
|
|
||||||
const authentication = {
|
const authentication = {
|
||||||
login({
|
login({
|
||||||
login = '',
|
login,
|
||||||
password = '',
|
password,
|
||||||
|
totp,
|
||||||
rememberMe = false
|
rememberMe = false
|
||||||
|
}: {
|
||||||
|
login: string,
|
||||||
|
password?: string,
|
||||||
|
totp?: string,
|
||||||
|
rememberMe: bool
|
||||||
}) {
|
}) {
|
||||||
return request.post(
|
return request.post(
|
||||||
'/api/authentication/login',
|
'/api/authentication/login',
|
||||||
{login, password, rememberMe},
|
{login, password, token: totp, rememberMe},
|
||||||
{token: null}
|
{token: null}
|
||||||
);
|
).catch((resp) => {
|
||||||
|
if (resp && resp.errors && resp.errors.token) {
|
||||||
|
resp.errors.totp = resp.errors.token.replace('token', 'totp');
|
||||||
|
delete resp.errors.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.reject(resp);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {object} options
|
* @param {object} options
|
||||||
* @param {object} [options.token] - an optional token to overwrite headers
|
* @param {string} [options.token] - an optional token to overwrite headers
|
||||||
* in middleware and disable token auto-refresh
|
* in middleware and disable token auto-refresh
|
||||||
*
|
*
|
||||||
* @return {Promise}
|
* @return {Promise}
|
||||||
*/
|
*/
|
||||||
logout(options = {}) {
|
logout(options: {
|
||||||
|
token?: string
|
||||||
|
} = {}) {
|
||||||
return request.post('/api/authentication/logout', {}, {
|
return request.post('/api/authentication/logout', {}, {
|
||||||
token: options.token
|
token: options.token
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
forgotPassword({
|
forgotPassword({
|
||||||
login = '',
|
login,
|
||||||
captcha = ''
|
captcha
|
||||||
|
}: {
|
||||||
|
login: string,
|
||||||
|
captcha: string
|
||||||
}) {
|
}) {
|
||||||
return request.post(
|
return request.post(
|
||||||
'/api/authentication/forgot-password',
|
'/api/authentication/forgot-password',
|
||||||
@ -39,9 +58,13 @@ const authentication = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
recoverPassword({
|
recoverPassword({
|
||||||
key = '',
|
key,
|
||||||
newPassword = '',
|
newPassword,
|
||||||
newRePassword = ''
|
newRePassword
|
||||||
|
}: {
|
||||||
|
key: string,
|
||||||
|
newPassword: string,
|
||||||
|
newRePassword: string
|
||||||
}) {
|
}) {
|
||||||
return request.post(
|
return request.post(
|
||||||
'/api/authentication/recover-password',
|
'/api/authentication/recover-password',
|
||||||
@ -61,7 +84,10 @@ const authentication = {
|
|||||||
* if it was refreshed. As a side effect the response
|
* if it was refreshed. As a side effect the response
|
||||||
* will have a `user` field with current user data
|
* will have a `user` field with current user data
|
||||||
*/
|
*/
|
||||||
validateToken({token, refreshToken}) {
|
validateToken({token, refreshToken}: {
|
||||||
|
token: string,
|
||||||
|
refreshToken: string
|
||||||
|
}) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (typeof token !== 'string') {
|
if (typeof token !== 'string') {
|
||||||
throw new Error('token must be a string');
|
throw new Error('token must be a string');
|
||||||
@ -91,12 +117,12 @@ const authentication = {
|
|||||||
*
|
*
|
||||||
* @return {Promise} - resolves to {token}
|
* @return {Promise} - resolves to {token}
|
||||||
*/
|
*/
|
||||||
requestToken(refreshToken) {
|
requestToken(refreshToken: string): Promise<{token: string}> {
|
||||||
return request.post(
|
return request.post(
|
||||||
'/api/authentication/refresh-token',
|
'/api/authentication/refresh-token',
|
||||||
{refresh_token: refreshToken}, // eslint-disable-line
|
{refresh_token: refreshToken}, // eslint-disable-line
|
||||||
{token: null}
|
{token: null}
|
||||||
).then((resp) => ({
|
).then((resp: {access_token: string}) => ({
|
||||||
token: resp.access_token
|
token: resp.access_token
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,8 @@ describe('authentication api', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sinon.stub(request, 'post').named('request.post');
|
sinon.stub(request, 'post').named('request.post');
|
||||||
|
|
||||||
|
request.post.returns(Promise.resolve());
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@ -3,11 +3,11 @@
|
|||||||
import type { AuthContext } from 'services/authFlow';
|
import type { AuthContext } from 'services/authFlow';
|
||||||
|
|
||||||
export default class AbstractState {
|
export default class AbstractState {
|
||||||
resolve(context: AuthContext, payload: Object) {}
|
resolve(context: AuthContext, payload: Object): void {}
|
||||||
goBack(context: AuthContext) {
|
goBack(context: AuthContext): void {
|
||||||
throw new Error('There is no way back');
|
throw new Error('There is no way back');
|
||||||
}
|
}
|
||||||
reject(context: AuthContext, payload: Object) {}
|
reject(context: AuthContext, payload: Object): void {}
|
||||||
enter(context: AuthContext) {}
|
enter(context: AuthContext): void {}
|
||||||
leave(context: AuthContext) {}
|
leave(context: AuthContext): void {}
|
||||||
}
|
}
|
||||||
|
@ -45,12 +45,16 @@ describe('AuthFlow.functional', () => {
|
|||||||
|
|
||||||
describe('guest', () => {
|
describe('guest', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
state.user = {
|
Object.assign(state, {
|
||||||
isGuest: true
|
user: {
|
||||||
};
|
isGuest: true,
|
||||||
state.auth = {
|
},
|
||||||
|
auth: {
|
||||||
|
credentials: {
|
||||||
login: null
|
login: null
|
||||||
};
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should redirect guest / -> /login', () => {
|
it('should redirect guest / -> /login', () => {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
// @flow
|
||||||
import { browserHistory } from 'services/history';
|
import { browserHistory } from 'services/history';
|
||||||
|
|
||||||
import logger from 'services/logger';
|
import logger from 'services/logger';
|
||||||
@ -13,18 +14,35 @@ import CompleteState from './CompleteState';
|
|||||||
import ResendActivationState from './ResendActivationState';
|
import ResendActivationState from './ResendActivationState';
|
||||||
import type AbstractState from './AbstractState';
|
import type AbstractState from './AbstractState';
|
||||||
|
|
||||||
export type AuthContext = {
|
type Request = {
|
||||||
run: (actionId: string, payload: Object) => any,
|
|
||||||
setState: (newState: AbstractState) => void,
|
|
||||||
getRequest: () => {
|
|
||||||
path: string,
|
path: string,
|
||||||
query: URLSearchParams,
|
query: URLSearchParams,
|
||||||
params: Object
|
params: Object
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
export interface AuthContext {
|
||||||
|
run(actionId: string, payload: *): *;
|
||||||
|
setState(newState: AbstractState): Promise<*>|void;
|
||||||
|
getState(): Object;
|
||||||
|
navigate(route: string): void;
|
||||||
|
getRequest(): Request;
|
||||||
|
}
|
||||||
|
|
||||||
export default class AuthFlow {
|
export default class AuthFlow implements AuthContext {
|
||||||
constructor(actions) {
|
actions: {[key: string]: Function};
|
||||||
|
state: AbstractState;
|
||||||
|
prevState: AbstractState;
|
||||||
|
/**
|
||||||
|
* A callback from router, that allows to replace (perform redirect) route
|
||||||
|
* during route transition
|
||||||
|
*/
|
||||||
|
replace: ?(string) => void;
|
||||||
|
onReady: Function;
|
||||||
|
navigate: Function;
|
||||||
|
currentRequest: Request;
|
||||||
|
dispatch: (action: Object) => void;
|
||||||
|
getState: () => Object;
|
||||||
|
|
||||||
|
constructor(actions: {[key: string]: Function}) {
|
||||||
if (typeof actions !== 'object') {
|
if (typeof actions !== 'object') {
|
||||||
throw new Error('AuthFlow requires an actions object');
|
throw new Error('AuthFlow requires an actions object');
|
||||||
}
|
}
|
||||||
@ -36,16 +54,18 @@ export default class AuthFlow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setStore(store) {
|
setStore(store: *) {
|
||||||
/**
|
/**
|
||||||
* @param {string} route
|
* @param {string} route
|
||||||
* @param {object} options
|
* @param {object} options
|
||||||
* @param {object} options.replace
|
* @param {object} options.replace
|
||||||
*/
|
*/
|
||||||
this.navigate = (route, options = {}) => {
|
this.navigate = (route: string, options: {replace?: bool} = {}) => {
|
||||||
if (this.getRequest().path !== route) {
|
if (this.getRequest().path !== route) {
|
||||||
this.currentRequest = {
|
this.currentRequest = {
|
||||||
path: route
|
path: route,
|
||||||
|
params: {},
|
||||||
|
query: new URLSearchParams()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.replace) {
|
if (this.replace) {
|
||||||
@ -62,11 +82,11 @@ export default class AuthFlow {
|
|||||||
this.dispatch = store.dispatch.bind(store);
|
this.dispatch = store.dispatch.bind(store);
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(payload = {}) {
|
resolve(payload: Object = {}) {
|
||||||
this.state.resolve(this, payload);
|
this.state.resolve(this, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
reject(payload = {}) {
|
reject(payload: Object = {}) {
|
||||||
this.state.reject(this, payload);
|
this.state.reject(this, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,15 +94,15 @@ export default class AuthFlow {
|
|||||||
this.state.goBack(this);
|
this.state.goBack(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
run(actionId, payload) {
|
run(actionId: string, payload: Object): Promise<*> {
|
||||||
if (!this.actions[actionId]) {
|
if (!this.actions[actionId]) {
|
||||||
throw new Error(`Action ${actionId} does not exists`);
|
throw new Error(`Action ${actionId} does not exists`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.dispatch(this.actions[actionId](payload));
|
return Promise.resolve(this.dispatch(this.actions[actionId](payload)));
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(state) {
|
setState(state: AbstractState) {
|
||||||
if (!state) {
|
if (!state) {
|
||||||
throw new Error('State is required');
|
throw new Error('State is required');
|
||||||
}
|
}
|
||||||
@ -128,7 +148,7 @@ export default class AuthFlow {
|
|||||||
* @param {function} [callback = function() {}] - an optional callback function to be called, when state will be stabilized
|
* @param {function} [callback = function() {}] - an optional callback function to be called, when state will be stabilized
|
||||||
* (state's enter function's promise resolved)
|
* (state's enter function's promise resolved)
|
||||||
*/
|
*/
|
||||||
handleRequest(request, replace, callback = function() {}) {
|
handleRequest(request: Request, replace: Function, callback: Function = function() {}) {
|
||||||
const {path} = request;
|
const {path} = request;
|
||||||
this.replace = replace;
|
this.replace = replace;
|
||||||
this.onReady = callback;
|
this.onReady = callback;
|
||||||
@ -165,6 +185,7 @@ export default class AuthFlow {
|
|||||||
case '/':
|
case '/':
|
||||||
case '/login':
|
case '/login':
|
||||||
case '/password':
|
case '/password':
|
||||||
|
case '/mfa':
|
||||||
case '/accept-rules':
|
case '/accept-rules':
|
||||||
case '/oauth/permissions':
|
case '/oauth/permissions':
|
||||||
case '/oauth/finish':
|
case '/oauth/finish':
|
||||||
|
@ -202,11 +202,11 @@ describe('AuthFlow', () => {
|
|||||||
expect(actions.test, 'to have a call satisfying', ['arg']);
|
expect(actions.test, 'to have a call satisfying', ['arg']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return action dispatch result', () => {
|
it('should resolve to action dispatch result', () => {
|
||||||
const expected = 'dispatch called';
|
const expected = 'dispatch called';
|
||||||
store.dispatch.returns(expected);
|
store.dispatch.returns(expected);
|
||||||
|
|
||||||
expect(flow.run('test'), 'to be', expected);
|
expect(flow.run('test'), 'to be fulfilled with', expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('throws when running unexisted action', () => {
|
it('throws when running unexisted action', () => {
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import logger from 'services/logger';
|
import logger from 'services/logger';
|
||||||
|
import { getLogin } from 'components/auth/reducer';
|
||||||
|
|
||||||
import AbstractState from './AbstractState';
|
import AbstractState from './AbstractState';
|
||||||
import PasswordState from './PasswordState';
|
import PasswordState from './PasswordState';
|
||||||
@ -6,13 +7,14 @@ import RegisterState from './RegisterState';
|
|||||||
|
|
||||||
export default class LoginState extends AbstractState {
|
export default class LoginState extends AbstractState {
|
||||||
enter(context) {
|
enter(context) {
|
||||||
const {auth, user} = context.getState();
|
const login = getLogin(context.getState());
|
||||||
|
const {user} = context.getState();
|
||||||
|
|
||||||
const isUserAddsSecondAccount = !user.isGuest
|
const isUserAddsSecondAccount = !user.isGuest
|
||||||
&& /login|password/.test(context.getRequest().path); // TODO: improve me
|
&& /login|password/.test(context.getRequest().path); // TODO: improve me
|
||||||
|
|
||||||
// TODO: it may not allow user to leave password state till he click back or enters password
|
// TODO: it may not allow user to leave password state till he click back or enters password
|
||||||
if (auth.login) {
|
if (login) {
|
||||||
context.setState(new PasswordState());
|
context.setState(new PasswordState());
|
||||||
} else if (user.isGuest || isUserAddsSecondAccount) {
|
} else if (user.isGuest || isUserAddsSecondAccount) {
|
||||||
context.navigate('/login');
|
context.navigate('/login');
|
||||||
|
@ -27,7 +27,9 @@ describe('LoginState', () => {
|
|||||||
it('should navigate to /login', () => {
|
it('should navigate to /login', () => {
|
||||||
context.getState.returns({
|
context.getState.returns({
|
||||||
user: {isGuest: true},
|
user: {isGuest: true},
|
||||||
auth: {login: null}
|
auth: {
|
||||||
|
credentials: {login: null}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expectNavigate(mock, '/login');
|
expectNavigate(mock, '/login');
|
||||||
@ -38,7 +40,9 @@ describe('LoginState', () => {
|
|||||||
it('should transition to password if login was set', () => {
|
it('should transition to password if login was set', () => {
|
||||||
context.getState.returns({
|
context.getState.returns({
|
||||||
user: {isGuest: true},
|
user: {isGuest: true},
|
||||||
auth: {login: 'foo'}
|
auth: {
|
||||||
|
credentials: {login: 'foo'}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expectState(mock, PasswordState);
|
expectState(mock, PasswordState);
|
||||||
|
49
src/services/authFlow/MfaState.js
Normal file
49
src/services/authFlow/MfaState.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// @flow
|
||||||
|
import logger from 'services/logger';
|
||||||
|
|
||||||
|
import { getCredentials } from 'components/auth/reducer';
|
||||||
|
|
||||||
|
import AbstractState from './AbstractState';
|
||||||
|
import CompleteState from './CompleteState';
|
||||||
|
import PasswordState from './PasswordState';
|
||||||
|
|
||||||
|
import type { AuthContext } from './AuthFlow';
|
||||||
|
|
||||||
|
export default class MfaState extends AbstractState {
|
||||||
|
enter(context: AuthContext) {
|
||||||
|
const {
|
||||||
|
login,
|
||||||
|
password,
|
||||||
|
isTotpRequired
|
||||||
|
} = getCredentials(context.getState());
|
||||||
|
|
||||||
|
if (login && password && isTotpRequired) {
|
||||||
|
context.navigate('/mfa');
|
||||||
|
} else {
|
||||||
|
context.setState(new CompleteState());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve(context: AuthContext, {totp}: {totp: string}) {
|
||||||
|
const {
|
||||||
|
login,
|
||||||
|
password,
|
||||||
|
rememberMe
|
||||||
|
} = getCredentials(context.getState());
|
||||||
|
|
||||||
|
context.run('login', {
|
||||||
|
totp,
|
||||||
|
password,
|
||||||
|
rememberMe,
|
||||||
|
login
|
||||||
|
})
|
||||||
|
.then(() => context.setState(new CompleteState()))
|
||||||
|
.catch((err = {}) =>
|
||||||
|
err.errors || logger.warn('Error logging in', err)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
goBack(context: AuthContext) {
|
||||||
|
context.setState(new PasswordState());
|
||||||
|
}
|
||||||
|
}
|
102
src/services/authFlow/MfaState.test.js
Normal file
102
src/services/authFlow/MfaState.test.js
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
import expect from 'unexpected';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
|
||||||
|
import MfaState from './MfaState';
|
||||||
|
import CompleteState from 'services/authFlow/CompleteState';
|
||||||
|
import PasswordState from 'services/authFlow/PasswordState';
|
||||||
|
|
||||||
|
import { bootstrap, expectState, expectNavigate, expectRun } from './helpers';
|
||||||
|
|
||||||
|
describe('MfaState', () => {
|
||||||
|
let state;
|
||||||
|
let context;
|
||||||
|
let mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = new MfaState();
|
||||||
|
|
||||||
|
const data = bootstrap();
|
||||||
|
context = data.context;
|
||||||
|
mock = data.mock;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mock.verify();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#enter', () => {
|
||||||
|
it('should navigate to /mfa', () => {
|
||||||
|
context.getState.returns({
|
||||||
|
auth: {
|
||||||
|
credentials: {
|
||||||
|
login: 'foo',
|
||||||
|
password: 'bar',
|
||||||
|
isTotpRequired: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expectNavigate(mock, '/mfa');
|
||||||
|
|
||||||
|
state.enter(context);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transition to complete if no totp required', () => {
|
||||||
|
context.getState.returns({
|
||||||
|
auth: {
|
||||||
|
credentials: {
|
||||||
|
login: 'foo',
|
||||||
|
password: 'bar'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expectState(mock, CompleteState);
|
||||||
|
|
||||||
|
state.enter(context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#resolve', () => {
|
||||||
|
it('should call login with login and password', () => {
|
||||||
|
const expectedLogin = 'foo';
|
||||||
|
const expectedPassword = 'bar';
|
||||||
|
const expectedTotp = '111222';
|
||||||
|
const expectedRememberMe = true;
|
||||||
|
|
||||||
|
context.getState.returns({
|
||||||
|
auth: {
|
||||||
|
credentials: {
|
||||||
|
login: expectedLogin,
|
||||||
|
password: expectedPassword,
|
||||||
|
rememberMe: expectedRememberMe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expectRun(
|
||||||
|
mock,
|
||||||
|
'login',
|
||||||
|
sinon.match({
|
||||||
|
totp: expectedTotp,
|
||||||
|
login: expectedLogin,
|
||||||
|
password: expectedPassword,
|
||||||
|
rememberMe: expectedRememberMe
|
||||||
|
})
|
||||||
|
).returns(Promise.resolve());
|
||||||
|
expectState(mock, CompleteState);
|
||||||
|
|
||||||
|
const payload = {totp: expectedTotp};
|
||||||
|
|
||||||
|
return expect(state.resolve(context, payload), 'to be fulfilled');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#goBack', () => {
|
||||||
|
it('should transition to login state', () => {
|
||||||
|
expectState(mock, PasswordState);
|
||||||
|
|
||||||
|
state.goBack(context);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
@ -1,40 +1,62 @@
|
|||||||
|
// @flow
|
||||||
import logger from 'services/logger';
|
import logger from 'services/logger';
|
||||||
|
import { getCredentials } from 'components/auth/reducer';
|
||||||
|
|
||||||
import AbstractState from './AbstractState';
|
import AbstractState from './AbstractState';
|
||||||
import CompleteState from './CompleteState';
|
import CompleteState from './CompleteState';
|
||||||
import ForgotPasswordState from './ForgotPasswordState';
|
import ForgotPasswordState from './ForgotPasswordState';
|
||||||
import LoginState from './LoginState';
|
import LoginState from './LoginState';
|
||||||
|
import MfaState from './MfaState';
|
||||||
|
|
||||||
|
import type { AuthContext } from './AuthFlow';
|
||||||
|
|
||||||
export default class PasswordState extends AbstractState {
|
export default class PasswordState extends AbstractState {
|
||||||
enter(context) {
|
enter(context: AuthContext) {
|
||||||
const {auth} = context.getState();
|
const {login} = getCredentials(context.getState());
|
||||||
|
|
||||||
if (auth.login) {
|
if (login) {
|
||||||
context.navigate('/password');
|
context.navigate('/password');
|
||||||
} else {
|
} else {
|
||||||
context.setState(new CompleteState());
|
context.setState(new CompleteState());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve(context, {password, rememberMe}) {
|
resolve(
|
||||||
const {auth: {login}} = context.getState();
|
context: AuthContext,
|
||||||
|
{
|
||||||
|
password,
|
||||||
|
rememberMe
|
||||||
|
}: {
|
||||||
|
password: string,
|
||||||
|
rememberMe: bool
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const {login} = getCredentials(context.getState());
|
||||||
|
|
||||||
return context.run('login', {
|
context.run('login', {
|
||||||
password,
|
password,
|
||||||
rememberMe,
|
rememberMe,
|
||||||
login
|
login
|
||||||
})
|
})
|
||||||
.then(() => context.setState(new CompleteState()))
|
.then(() => {
|
||||||
|
const {isTotpRequired} = getCredentials(context.getState());
|
||||||
|
|
||||||
|
if (isTotpRequired) {
|
||||||
|
return context.setState(new MfaState());
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.setState(new CompleteState());
|
||||||
|
})
|
||||||
.catch((err = {}) =>
|
.catch((err = {}) =>
|
||||||
err.errors || logger.warn('Error logging in', err)
|
err.errors || logger.warn('Error logging in', err)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
reject(context) {
|
reject(context: AuthContext) {
|
||||||
context.setState(new ForgotPasswordState());
|
context.setState(new ForgotPasswordState());
|
||||||
}
|
}
|
||||||
|
|
||||||
goBack(context) {
|
goBack(context: AuthContext) {
|
||||||
context.run('setLogin', null);
|
context.run('setLogin', null);
|
||||||
context.setState(new LoginState());
|
context.setState(new LoginState());
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,9 @@ describe('PasswordState', () => {
|
|||||||
it('should navigate to /password', () => {
|
it('should navigate to /password', () => {
|
||||||
context.getState.returns({
|
context.getState.returns({
|
||||||
user: {isGuest: true},
|
user: {isGuest: true},
|
||||||
auth: {login: 'foo'}
|
auth: {
|
||||||
|
credentials: {login: 'foo'}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expectNavigate(mock, '/password');
|
expectNavigate(mock, '/password');
|
||||||
@ -40,7 +42,9 @@ describe('PasswordState', () => {
|
|||||||
it('should transition to complete if not guest', () => {
|
it('should transition to complete if not guest', () => {
|
||||||
context.getState.returns({
|
context.getState.returns({
|
||||||
user: {isGuest: false},
|
user: {isGuest: false},
|
||||||
auth: {login: null}
|
auth: {
|
||||||
|
credentials: {login: null}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expectState(mock, CompleteState);
|
expectState(mock, CompleteState);
|
||||||
@ -57,8 +61,10 @@ describe('PasswordState', () => {
|
|||||||
|
|
||||||
context.getState.returns({
|
context.getState.returns({
|
||||||
auth: {
|
auth: {
|
||||||
|
credentials: {
|
||||||
login: expectedLogin
|
login: expectedLogin
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expectRun(
|
expectRun(
|
||||||
|
Loading…
Reference in New Issue
Block a user