#305: implement disable mfa form

This commit is contained in:
SleepWalker 2017-09-09 17:22:19 +03:00
parent 02ce9bb3b5
commit d1b19a2285
20 changed files with 474 additions and 218 deletions

View File

@ -6,7 +6,8 @@
},
"plugins": [
"react"
"react",
"flowtype"
],
"env": {
@ -16,7 +17,10 @@
"es6": true
},
"extends": "eslint:recommended",
"extends": [
"eslint:recommended",
"plugin:flowtype/recommended"
],
// @see: http://eslint.org/docs/rules/
"rules": {
@ -200,6 +204,8 @@
"react/prefer-es6-class": "warn",
"react/prop-types": "off", // using flowtype for this task
"react/self-closing-comp": "warn",
"react/sort-comp": ["off", {"order": ["lifecycle", "render", "everything-else"]}]
"react/sort-comp": ["off", {"order": ["lifecycle", "render", "everything-else"]}],
"flowtype/boolean-style": ["error", "bool"],
}
}

View File

@ -29,7 +29,7 @@
"intl": "^1.2.2",
"intl-format-cache": "^2.0.4",
"intl-messageformat": "^2.1.0",
"promise.prototype.finally": "^3.0.0",
"promise.prototype.finally": "3.0.1",
"raven-js": "^3.8.1",
"react": "^15.0.0",
"react-dom": "^15.0.0",
@ -67,11 +67,12 @@
"css-loader": "^0.28.0",
"enzyme": "^2.2.0",
"eslint": "^4.0.0",
"eslint-plugin-flowtype": "2.35.1",
"eslint-plugin-react": "^7.3.0",
"exports-loader": "^0.6.3",
"extract-text-webpack-plugin": "^1.0.0",
"file-loader": "^0.11.0",
"flow-bin": "^0.53.1",
"flow-bin": "0.54.1",
"fontgen-loader": "^0.2.1",
"html-loader": "^0.4.3",
"html-webpack-plugin": "^2.0.0",

View File

@ -36,7 +36,7 @@ export function goBack(fallbackUrl?: ?string = null) {
};
}
export function redirect(url: string) {
export function redirect(url: string): () => Promise<*> {
loader.show();
return () => new Promise(() => {
@ -508,7 +508,7 @@ function authHandler(dispatch) {
});
}
function validationErrorsHandler(dispatch: (Function|Object) => void, repeatUrl?: string) {
function validationErrorsHandler(dispatch: (Function | Object) => void, repeatUrl?: string) {
return (resp) => {
if (resp.errors) {
const firstError = Object.keys(resp.errors)[0];

View File

@ -0,0 +1,54 @@
// @flow
import React, { Component } from 'react';
import logger from 'services/logger';
import mfa from 'services/api/mfa';
import MfaDisableForm from './disableForm/MfaDisableForm';
import MfaStatus from './status/MfaStatus';
import type { FormModel } from 'components/ui/form';
export default class MfaDisable extends Component<{
onSubmit: (form: FormModel, sendData: () => Promise<*>) => Promise<*>,
onComplete: Function
}, {
showForm?: bool
}> {
state = {};
render() {
const { showForm } = this.state;
return showForm ? (
<MfaDisableForm onSubmit={this.onSubmit}/>
) : (
<MfaStatus onProceed={this.onProceed} />
);
}
onProceed = () => this.setState({showForm: true});
onSubmit = (form: FormModel) => {
return this.props.onSubmit(
form,
() => {
const data = form.serialize();
return mfa.disable(data);
}
)
.then(() => this.props.onComplete())
.catch((resp) => {
const {errors} = resp || {};
if (errors) {
return Promise.reject(errors);
}
logger.error('MFA: Unexpected disable form result', {
resp
});
});
};
}

View File

@ -0,0 +1,171 @@
// @flow
import React, { Component } from 'react';
import { Button, FormModel } from 'components/ui/form';
import styles from 'components/profile/profileForm.scss';
import Stepper from 'components/ui/stepper';
import { ScrollMotion } from 'components/ui/motion';
import logger from 'services/logger';
import mfa from 'services/api/mfa';
import Instructions from './instructions';
import KeyForm from './keyForm';
import Confirmation from './confirmation';
import messages from './MultiFactorAuth.intl.json';
import type { Form } from 'components/ui/form';
const STEPS_TOTAL = 3;
export type MfaStep = 0|1|2;
type Props = {
onChangeStep: Function,
confirmationForm: FormModel,
onSubmit: (form: FormModel, sendData: () => Promise<*>) => Promise<*>,
onComplete: Function,
step: MfaStep
};
export default class MfaEnable extends Component<Props, {
isLoading: bool,
activeStep: MfaStep,
secret: string,
qrCodeSrc: string
}> {
static defaultProps = {
confirmationForm: new FormModel(),
step: 0
};
state = {
isLoading: false,
activeStep: this.props.step,
qrCodeSrc: '',
secret: ''
};
confirmationFormEl: ?Form;
componentWillMount() {
this.syncState(this.props);
}
componentWillReceiveProps(nextProps: Props) {
this.syncState(nextProps);
}
render() {
const {activeStep, isLoading} = this.state;
const stepsData = [
{
buttonLabel: messages.theAppIsInstalled,
buttonAction: () => this.nextStep()
},
{
buttonLabel: messages.ready,
buttonAction: () => this.nextStep()
},
{
buttonLabel: messages.enableTwoFactorAuth,
buttonAction: () => this.confirmationFormEl && this.confirmationFormEl.submit()
}
];
const {buttonLabel, buttonAction} = stepsData[activeStep];
return (
<div>
<div className={styles.stepper}>
<Stepper totalSteps={STEPS_TOTAL} activeStep={activeStep} />
</div>
<div className={styles.form}>
{this.renderStepForms()}
<Button
color="green"
onClick={buttonAction}
loading={isLoading}
block
label={buttonLabel}
/>
</div>
</div>
);
}
renderStepForms() {
const {activeStep, secret, qrCodeSrc} = this.state;
return (
<ScrollMotion activeStep={activeStep}>
{[
<Instructions key="step1" />,
<KeyForm key="step2"
secret={secret}
qrCodeSrc={qrCodeSrc}
/>,
<Confirmation key="step3"
form={this.props.confirmationForm}
formRef={(el: Form) => this.confirmationFormEl = el}
onSubmit={this.onTotpSubmit}
onInvalid={() => this.forceUpdate()}
/>
]}
</ScrollMotion>
);
}
syncState(props: Props) {
if (props.step === 1) {
this.setState({isLoading: true});
mfa.getSecret().then((resp) => {
this.setState({
isLoading: false,
secret: resp.secret,
qrCodeSrc: resp.qr
});
});
}
this.setState({
activeStep: typeof props.step === 'number' ? props.step : this.state.activeStep
});
}
nextStep() {
const nextStep = this.state.activeStep + 1;
if (nextStep < STEPS_TOTAL) {
this.props.onChangeStep(nextStep);
}
}
onTotpSubmit = (form: FormModel): Promise<*> => {
this.setState({isLoading: true});
return this.props.onSubmit(
form,
() => {
const data = form.serialize();
return mfa.enable(data);
}
)
.then(() => this.props.onComplete())
.catch((resp) => {
const {errors} = resp || {};
if (errors) {
return Promise.reject(errors);
}
logger.error('MFA: Unexpected form submit result', {
resp
});
})
.finally(() => this.setState({isLoading: false}));
};
}

View File

@ -15,5 +15,11 @@
"codePlaceholder": "Enter the code here",
"enterCodeFromApp": "In order to finish two-factor auth setup, please enter the code received in mobile app:",
"enableTwoFactorAuth": "Enable two-factor auth"
"enableTwoFactorAuth": "Enable two-factor auth",
"disable": "Disable",
"mfaEnabledForYourAcc": "The two-factor authentication is enabled for yout account",
"mfaLoginFlowDesc": "In order to log in next time, you'll need to enter additional code. Please note, that Minecraft authorization won't work with two-factor auth enabled.",
"disableMfa": "Disable two-factor authentication",
"disableMfaInstruction": "In order to disable two-factor authentication, you need to provide a code from your mobile app and finally confirm your action with your current account password."
}

View File

@ -1,89 +1,32 @@
// @flow
import React, { Component } from 'react';
import { FormattedMessage as Message } from 'react-intl';
import Helmet from 'react-helmet';
import { FormattedMessage as Message } from 'react-intl';
import { Button, FormModel } from 'components/ui/form';
import { BackButton } from 'components/profile/ProfileForm';
import styles from 'components/profile/profileForm.scss';
import Stepper from 'components/ui/stepper';
import { ScrollMotion } from 'components/ui/motion';
import logger from 'services/logger';
import mfa from 'services/api/mfa';
import { BackButton } from 'components/profile/ProfileForm';
import Instructions from './instructions';
import KeyForm from './keyForm';
import Confirmation from './confirmation';
import MfaEnable from './MfaEnable';
import MfaDisable from './MfaDisable';
import messages from './MultiFactorAuth.intl.json';
import type { Form } from 'components/ui/form';
import type { MfaStep } from './MfaEnable';
const STEPS_TOTAL = 3;
export type MfaStep = 0|1|2;
type Props = {
onChangeStep: Function,
lang: string,
email: string,
confirmationForm: FormModel,
onSubmit: (form: FormModel, sendData: () => Promise<*>) => Promise<*>,
class MultiFactorAuth extends Component<{
step: MfaStep,
isMfaEnabled: bool,
onSubmit: Function,
onComplete: Function,
step: MfaStep
};
export default class MultiFactorAuth extends Component<Props, {
isLoading: bool,
activeStep: MfaStep,
secret: string,
qrCodeSrc: string
onChangeStep: Function
}> {
static defaultProps = {
confirmationForm: new FormModel(),
step: 0
};
state: {
isLoading: bool,
activeStep: MfaStep,
secret: string,
qrCodeSrc: string
} = {
isLoading: false,
activeStep: this.props.step,
qrCodeSrc: '',
secret: ''
};
confirmationFormEl: ?Form;
componentWillMount() {
this.syncState(this.props);
}
componentWillReceiveProps(nextProps: Props) {
this.syncState(nextProps);
}
render() {
const {activeStep, isLoading} = this.state;
const stepsData = [
{
buttonLabel: messages.theAppIsInstalled,
buttonAction: () => this.nextStep()
},
{
buttonLabel: messages.ready,
buttonAction: () => this.nextStep()
},
{
buttonLabel: messages.enableTwoFactorAuth,
buttonAction: () => this.confirmationFormEl && this.confirmationFormEl.submit()
}
];
const {buttonLabel, buttonAction} = stepsData[activeStep];
const {
step,
onSubmit,
onComplete,
onChangeStep,
isMfaEnabled
} = this.props;
return (
<div className={styles.contentWithBackButton}>
@ -106,99 +49,26 @@ export default class MultiFactorAuth extends Component<Props, {
</p>
</div>
</div>
{isMfaEnabled && (
<MfaDisable
onSubmit={onSubmit}
onComplete={onComplete}
/>
)}
</div>
<div className={styles.stepper}>
<Stepper totalSteps={STEPS_TOTAL} activeStep={activeStep} />
</div>
<div className={styles.form}>
{this.renderStepForms()}
<Button
color="green"
onClick={buttonAction}
loading={isLoading}
block
label={buttonLabel}
{isMfaEnabled || (
<MfaEnable
step={step}
onSubmit={onSubmit}
onChangeStep={onChangeStep}
onComplete={onComplete}
/>
</div>
)}
</div>
);
}
renderStepForms() {
const {activeStep, secret, qrCodeSrc} = this.state;
return (
<ScrollMotion activeStep={activeStep}>
{[
<Instructions key="step1" />,
<KeyForm key="step2"
secret={secret}
qrCodeSrc={qrCodeSrc}
/>,
<Confirmation key="step3"
form={this.props.confirmationForm}
formRef={(el: Form) => this.confirmationFormEl = el}
onSubmit={this.onTotpSubmit}
onInvalid={() => this.forceUpdate()}
/>
]}
</ScrollMotion>
);
}
syncState(props: Props) {
if (props.step === 1) {
this.setState({isLoading: true});
mfa.getSecret().then((resp) => {
this.setState({
isLoading: false,
secret: resp.secret,
qrCodeSrc: resp.qr
});
});
}
this.setState({
activeStep: typeof props.step === 'number' ? props.step : this.state.activeStep
});
}
nextStep() {
const nextStep = this.state.activeStep + 1;
if (nextStep < STEPS_TOTAL) {
this.props.onChangeStep(nextStep);
} else {
this.props.onComplete();
}
}
onTotpSubmit = (form: FormModel): Promise<*> => {
this.setState({isLoading: true});
return this.props.onSubmit(
form,
() => {
const data = form.serialize();
return mfa.enable(data);
}
)
.catch((resp) => {
const {errors} = resp || {};
if (errors) {
return Promise.reject(errors);
}
logger.error('MFA: Unexpected form submit result', {
resp
});
})
.finally(() => this.setState({isLoading: false}));
};
}
export default MultiFactorAuth;

View File

@ -0,0 +1,57 @@
// @flow
import React, { Component } from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Button, Input, Form, FormModel } from 'components/ui/form';
import styles from 'components/profile/profileForm.scss';
import messages from '../MultiFactorAuth.intl.json';
import mfaStyles from '../mfa.scss';
export default class MfaDisableForm extends Component<{
onSubmit: (form: FormModel) => Promise<*>
}> {
form: FormModel = new FormModel();
render() {
const { form } = this;
const {onSubmit} = this.props;
return (
<Form form={form}
onSubmit={onSubmit}
>
<div className={styles.formBody}>
<div className={styles.formRow}>
<p className={`${styles.description} ${mfaStyles.mfaTitle}`}>
<Message {...messages.disableMfa} />
</p>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.disableMfaInstruction} />
</p>
</div>
<div className={styles.formRow}>
<Input {...form.bindField('totp')}
required
autoFocus
autoComplete="off"
skin="light"
placeholder={messages.codePlaceholder}
/>
</div>
</div>
<Button
type="submit"
color="green"
block
label={messages.disable}
/>
</Form>
);
}
}

View File

@ -1 +1,2 @@
export { default, MfaStep } from './MultiFactorAuth';
export { default } from './MultiFactorAuth';
export type { MfaStep } from './MfaEnable';

View File

@ -0,0 +1,21 @@
@import '~components/ui/colors.scss';
@import '~components/ui/fonts.scss';
.mfaTitle {
font-size: 18px;
font-family: $font-family-title;
text-align: center;
margin-left: 60px;
margin-right: 60px;
}
.bigIcon {
color: $blue;
font-size: 100px;
line-height: 1;
text-align: center;
}
.disableMfa {
text-align: center;
}

View File

@ -0,0 +1,43 @@
// @flow
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import styles from 'components/profile/profileForm.scss';
import icons from 'components/ui/icons.scss';
import messages from '../MultiFactorAuth.intl.json';
import mfaStyles from '../mfa.scss';
export default function MfaStatus({onProceed} : {
onProceed: Function
}) {
return (
<div className={styles.formBody}>
<div className={styles.formRow}>
<div className={mfaStyles.bigIcon}>
<span className={icons.lock} />
</div>
<p className={`${styles.description} ${mfaStyles.mfaTitle}`}>
<Message {...messages.mfaEnabledForYourAcc} />
</p>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.mfaLoginFlowDesc} />
</p>
</div>
<div className={`${styles.formRow} ${mfaStyles.disableMfa}`}>
<p className={styles.description}>
<a href="#" onClick={(event) => {
event.preventDefault();
onProceed();
}}>
<Message {...messages.disable} />
</a>
</p>
</div>
</div>
);
}

View File

@ -110,12 +110,15 @@ export default class Form extends Component<Props, State> {
}
if (form.checkValidity()) {
this.setState({isLoading: true});
Promise.resolve(this.props.onSubmit(
this.props.form ? this.props.form : new FormData(form)
))
.catch((errors: {[key: string]: string}) => {
this.setErrors(errors);
});
})
.finally(() => this.setState({isLoading: false}));
} else {
const invalidEls = form.querySelectorAll(':invalid');
const errors = {};
@ -129,6 +132,7 @@ export default class Form extends Component<Props, State> {
}
let errorMessage = el.validationMessage;
if (el.validity.valueMissing) {
errorMessage = `error.${el.name}_required`;
} else if (el.validity.typeMismatch) {

View File

@ -101,7 +101,7 @@ function createOnOutsideComponentClickHandler(
// 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: MouseEvent & {target: HTMLElement}) => {
return (event: {target: HTMLElement} & MouseEvent) => {
const el = getEl();
if (isActive() && el) {

View File

@ -1,18 +1,21 @@
// @flow
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import MultiFactorAuth, { MfaStep } from 'components/profile/multiFactorAuth';
import MultiFactorAuth from 'components/profile/multiFactorAuth';
import type { MfaStep } from 'components/profile/multiFactorAuth';
import type { FormModel } from 'components/ui/form';
import type { User } from 'components/user';
class MultiFactorAuthPage extends Component<{
user: User,
history: {
push: (string) => void
},
match: {
params: {
step?: '1'|'2'|'3'
step?: '1' | '2' | '3'
}
}
}> {
@ -23,18 +26,28 @@ class MultiFactorAuthPage extends Component<{
componentWillMount() {
const step = this.props.match.params.step;
const {user} = this.props;
if (step && !/^[1-3]$/.test(step)) {
// wrong param value
this.props.history.push('/404');
if (step) {
if (!/^[1-3]$/.test(step)) {
// wrong param value
this.props.history.push('/404');
return;
}
if (user.isOtpEnabled) {
this.props.history.push('/mfa');
}
}
}
render() {
const step = (parseInt(this.props.match.params.step, 10) || 1) - 1;
const {user} = this.props;
return (
<MultiFactorAuth
isMfaEnabled={user.isOtpEnabled}
onSubmit={this.onSubmit}
step={step}
onChangeStep={this.onChangeStep}
@ -59,4 +72,4 @@ class MultiFactorAuthPage extends Component<{
};
}
export default MultiFactorAuthPage;
export default connect(({user}) => ({user}))(MultiFactorAuthPage);

View File

@ -1,9 +1,12 @@
// @flow
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Route, Switch, Redirect } from 'react-router-dom';
import { connect } from 'react-redux';
import { fetchUserData } from 'components/user/actions';
import { create as createPopup } from 'components/ui/popup/actions';
import PasswordRequestForm from 'components/profile/passwordRequestForm/PasswordRequestForm';
import logger from 'services/logger';
import { browserHistory } from 'services/history';
import { FooterMenu } from 'components/footerMenu';
@ -57,11 +60,6 @@ class ProfilePage extends Component<{
goToProfile = () => browserHistory.push('/');
}
import { connect } from 'react-redux';
import { fetchUserData } from 'components/user/actions';
import { create as createPopup } from 'components/ui/popup/actions';
import PasswordRequestForm from 'components/profile/passwordRequestForm/PasswordRequestForm';
export default connect(null, {
fetchUserData,
onSubmit: ({form, sendData}: {
@ -69,6 +67,7 @@ export default connect(null, {
sendData: () => Promise<*>
}) => (dispatch) => {
form.beginLoading();
return sendData()
.catch((resp) => {
const requirePassword = resp.errors && !!resp.errors.password;

View File

@ -128,10 +128,10 @@ export default class RulesPage extends Component<{
);
}
onRuleClick(event: MouseEvent & {target: HTMLElement, currentTarget: HTMLElement}) {
onRuleClick(event: SyntheticMouseEvent<HTMLElement>) {
if (event.defaultPrevented
|| !event.currentTarget.id
|| event.target.tagName.toLowerCase() === 'a'
|| event.target instanceof HTMLAnchorElement
) {
// some-one have already processed this event or it is a link
return;

View File

@ -27,9 +27,7 @@ describe('RulesPage', () => {
const expectedUrl = `/foo?bar#${id}`;
page.find(`#${id}`).simulate('click', {
target: {
tagName: 'li'
},
target: document.createElement('li'),
currentTarget: {
id
@ -41,9 +39,7 @@ describe('RulesPage', () => {
it('should not update location if link was clicked', () => {
page.find(`#${id}`).simulate('click', {
target: {
tagName: 'a'
},
target: document.createElement('a'),
currentTarget: {
id

View File

@ -16,16 +16,9 @@ const authentication = {
}) {
return request.post(
'/api/authentication/login',
{login, password, token: totp, rememberMe},
{login, password, totp, rememberMe},
{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);
});
);
},
/**

View File

@ -9,17 +9,15 @@ export default {
enable(data: {totp: string, password?: string}): Promise<Resp<*>> {
return request.post('/api/two-factor-auth', {
token: data.totp,
totp: data.totp,
password: data.password || ''
}).catch((resp) => {
if (resp.errors) {
if (resp.errors.token) {
resp.errors.totp = resp.errors.token.replace('token', 'totp');
delete resp.errors.token;
}
}
});
},
return Promise.reject(resp);
disable(data: {totp: string, password?: string}): Promise<Resp<*>> {
return request.delete('/api/two-factor-auth', {
totp: data.totp,
password: data.password || ''
});
}
};

View File

@ -41,14 +41,29 @@ export default {
* @return {Promise}
*/
get<T>(url: string, data?: Object, options: Object = {}): Promise<Resp<T>> {
if (typeof data === 'object' && Object.keys(data).length) {
const separator = url.indexOf('?') === -1 ? '?' : '&';
url += separator + buildQuery(data);
}
url = buildUrl(url, data);
return doFetch(url, options);
},
/**
* @param {string} url
* @param {object} [data] - request data
* @param {object} [options] - additional options for fetch or middlewares
*
* @return {Promise}
*/
delete<T>(url: string, data?: Object, options: Object = {}): Promise<Resp<T>> {
return doFetch(url, {
method: 'DELETE',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
},
body: buildQuery(data),
...options
});
},
/**
* Serializes object into encoded key=value presentation
*
@ -75,7 +90,6 @@ export default {
}
};
const checkStatus = (resp) => resp.status >= 200 && resp.status < 300
? Promise.resolve(resp)
: Promise.reject(resp);
@ -160,3 +174,12 @@ function buildQuery(data: Object = {}): string {
)
.join('&');
}
function buildUrl(url: string, data?: Object): string {
if (typeof data === 'object' && Object.keys(data).length) {
const separator = url.indexOf('?') === -1 ? '?' : '&';
url += separator + buildQuery(data);
}
return url;
}