From 42399ef9bf8ff9b1fa1595fde311d5ce83a1364d Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sun, 20 Aug 2017 18:45:21 +0300 Subject: [PATCH] #305: fully connect mfa ui with backend --- src/components/profile/Profile.intl.json | 1 + src/components/profile/Profile.js | 7 +- .../multiFactorAuth/MultiFactorAuth.js | 225 ++++++++---------- .../confirmation/Confirmation.js | 48 ++-- .../profile/multiFactorAuth/index.js | 2 +- src/components/ui/buttons.scss | 10 + src/components/ui/form/Button.js | 47 ++-- src/components/ui/form/Form.js | 78 +++--- src/components/ui/form/form.scss | 3 +- src/components/user/reducer.js | 3 +- src/pages/profile/MultiFactorAuthPage.js | 84 ++----- src/pages/profile/ProfilePage.js | 18 +- src/services/api/mfa.js | 16 ++ src/services/errorsDict/errorsDict.intl.json | 3 + src/services/errorsDict/errorsDict.js | 4 + 15 files changed, 275 insertions(+), 274 deletions(-) diff --git a/src/components/profile/Profile.intl.json b/src/components/profile/Profile.intl.json index 2085c59..9d26091 100644 --- a/src/components/profile/Profile.intl.json +++ b/src/components/profile/Profile.intl.json @@ -7,6 +7,7 @@ "projectRules": "project rules", "changedAt": "Changed {at}", "disabled": "Disabled", + "enabled": "Enabled", "nickname": "Nickname", "password": "Password", "twoFactorAuth": "Two factor auth" diff --git a/src/components/profile/Profile.js b/src/components/profile/Profile.js index dc5db82..d77593d 100644 --- a/src/components/profile/Profile.js +++ b/src/components/profile/Profile.js @@ -91,8 +91,13 @@ class Profile extends Component { /> } - value={} + value={user.isOtpEnabled ? ( + + ) : ( + + )} /> Promise<*>) => Promise<*>, + onComplete: Function, + step: MfaStep }; export default class MultiFactorAuth extends Component { props: Props; static defaultProps = { - stepForm: new FormModel(), - onChangeStep() {}, + confirmationForm: new FormModel(), step: 0 }; state: { isLoading: bool, - activeStep: number, + activeStep: MfaStep, secret: string, - qrCodeSrc: string, - code: string, - newEmail: ?string + qrCodeSrc: string } = { isLoading: false, activeStep: this.props.step, qrCodeSrc: '', - secret: '', - code: this.props.code || '', - newEmail: null + secret: '' }; + confirmationFormEl: ?Form; + componentWillMount() { this.syncState(this.props); } @@ -64,100 +64,84 @@ export default class MultiFactorAuth extends Component { render() { const {activeStep, isLoading} = this.state; - const form = this.props.stepForm; const stepsData = [ { - buttonLabel: messages.theAppIsInstalled + buttonLabel: messages.theAppIsInstalled, + buttonAction: () => this.nextStep() }, { - buttonLabel: messages.ready + buttonLabel: messages.ready, + buttonAction: () => this.nextStep() }, { - buttonLabel: messages.enableTwoFactorAuth + buttonLabel: messages.enableTwoFactorAuth, + buttonAction: () => this.confirmationFormEl && this.confirmationFormEl.submit() } ]; - const buttonLabel = stepsData[activeStep].buttonLabel; + const {buttonLabel, buttonAction} = stepsData[activeStep]; return ( -
this.forceUpdate()} - > -
- +
+ -
-
- - {(pageTitle) => ( -

- - {pageTitle} -

- )} -
+
+
+ + {(pageTitle) => ( +

+ + {pageTitle} +

+ )} +
-
-

- -

-
+
+

+ +

- -
- -
- -
- {this.renderStepForms()} - -
- - {this.isLastStep() || 1 ? null : ( -
- - - -
- )}
- + +
+ +
+ +
+ {this.renderStepForms()} + +
+
); } renderStepForms() { const {activeStep, secret, qrCodeSrc} = this.state; - const steps = [ - () => , - () => ( - - ), - () => ( - - ) - ]; - return ( - {steps.map((renderStep) => renderStep())} + {[ + , + , + this.confirmationFormEl = el} + onSubmit={this.onTotpSubmit} + onInvalid={() => this.forceUpdate()} + /> + ]} ); } @@ -165,72 +149,53 @@ export default class MultiFactorAuth extends Component { syncState(props: Props) { if (props.step === 1) { this.setState({isLoading: true}); + mfa.getSecret().then((resp) => { this.setState({ isLoading: false, - activeStep: props.step, secret: resp.secret, qrCodeSrc: resp.qr }); }); - } else { - this.setState({ - activeStep: typeof props.step === 'number' ? props.step : this.state.activeStep, - code: props.code || '' - }); } + + this.setState({ + activeStep: typeof props.step === 'number' ? props.step : this.state.activeStep + }); } nextStep() { - const {activeStep} = this.state; - const nextStep = activeStep + 1; - const newEmail = null; + const nextStep = this.state.activeStep + 1; if (nextStep < STEPS_TOTAL) { - this.setState({ - activeStep: nextStep, - newEmail - }); - this.props.onChangeStep(nextStep); + } else { + this.props.onComplete(); } } - isLastStep() { - return this.state.activeStep + 1 === STEPS_TOTAL; - } + onTotpSubmit = (form: FormModel): Promise<*> => { + this.setState({isLoading: true}); - onSwitchStep = (event: Event) => { - event.preventDefault(); + return this.props.onSubmit( + form, + () => { + const data = form.serialize(); - this.nextStep(); - }; + return mfa.enable(data); + } + ) + .catch((resp) => { + const {errors} = resp || {}; - onCodeInput = (event: {target: HTMLInputElement}) => { - const {value} = event.target; + if (errors) { + return Promise.reject(errors); + } - this.setState({ - code: this.props.code || value - }); - }; - - onFormSubmit = () => { - this.nextStep(); - // const {activeStep} = this.state; - // const form = this.props.stepForms[activeStep]; - // const promise = this.props.onSubmit(activeStep, form); - // - // if (!promise || !promise.then) { - // throw new Error('Expecting promise from onSubmit'); - // } - // - // promise.then(() => this.nextStep(), (resp) => { - // if (resp.errors) { - // form.setErrors(resp.errors); - // this.forceUpdate(); - // } else { - // return Promise.reject(resp); - // } - // }); + logger.error('MFA: Unexpected form submit result', { + resp + }); + }) + .finally(() => this.setState({isLoading: false})); }; } diff --git a/src/components/profile/multiFactorAuth/confirmation/Confirmation.js b/src/components/profile/multiFactorAuth/confirmation/Confirmation.js index b034bc7..cadd7da 100644 --- a/src/components/profile/multiFactorAuth/confirmation/Confirmation.js +++ b/src/components/profile/multiFactorAuth/confirmation/Confirmation.js @@ -3,38 +3,44 @@ import React from 'react'; import { FormattedMessage as Message } from 'react-intl'; -import { Input, FormModel } from 'components/ui/form'; +import { Input, Form, FormModel } from 'components/ui/form'; import profileForm from 'components/profile/profileForm.scss'; import messages from '../MultiFactorAuth.intl.json'; export default function Confirmation({ form, - isActiveStep, - onCodeInput + formRef = () => {}, + onSubmit, + onInvalid }: { form: FormModel, - isActiveStep: bool, - onCodeInput: (event: Event & {target: HTMLInputElement}) => void + formRef?: (el: ?Form) => void, + onSubmit: () => Promise<*>, + onInvalid: Function }) { return ( -
-
-

- -

-
+
+
+
+

+ +

+
-
- +
+ +
-
+
); } diff --git a/src/components/profile/multiFactorAuth/index.js b/src/components/profile/multiFactorAuth/index.js index a924b0d..e4c48a3 100644 --- a/src/components/profile/multiFactorAuth/index.js +++ b/src/components/profile/multiFactorAuth/index.js @@ -1 +1 @@ -export { default } from './MultiFactorAuth'; +export { default, MfaStep } from './MultiFactorAuth'; diff --git a/src/components/ui/buttons.scss b/src/components/ui/buttons.scss index f47ce12..4f35fd5 100644 --- a/src/components/ui/buttons.scss +++ b/src/components/ui/buttons.scss @@ -79,3 +79,13 @@ display: block; width: 100%; } + +.loading { + background: url('./form/images/loader_button.gif') #95a5a6 center center !important; + + cursor: default; + color: #fff; + transition: 0.25s; + outline: none; + pointer-events: none; +} diff --git a/src/components/ui/form/Button.js b/src/components/ui/form/Button.js index 245ca24..bef73e8 100644 --- a/src/components/ui/form/Button.js +++ b/src/components/ui/form/Button.js @@ -1,27 +1,23 @@ -import React, { PropTypes } from 'react'; +// @flow +import React from 'react'; import classNames from 'classnames'; import buttons from 'components/ui/buttons.scss'; -import { colors, COLOR_GREEN } from 'components/ui'; -import { omit } from 'functions'; +import { COLOR_GREEN } from 'components/ui'; import FormComponent from './FormComponent'; -export default class Button extends FormComponent { - static displayName = 'Button'; +import type { Color } from 'components/ui'; - static propTypes = { - label: PropTypes.oneOfType([ - PropTypes.shape({ - id: PropTypes.string - }), - PropTypes.string - ]).isRequired, - block: PropTypes.bool, - small: PropTypes.bool, - color: PropTypes.oneOf(colors), - className: PropTypes.string +export default class Button extends FormComponent { + props: { + label: string | {id: string}, + block: bool, + small: bool, + loading: bool, + className: string, + color: Color }; static defaultProps = { @@ -29,20 +25,25 @@ export default class Button extends FormComponent { }; render() { - const { color, block, small, className } = this.props; - - const props = omit(this.props, Object.keys(Button.propTypes)); - - const label = this.formatMessage(this.props.label); + const { + color, + block, + small, + className, + loading, + label, + ...restProps + } = this.props; return ( ); } diff --git a/src/components/ui/form/Form.js b/src/components/ui/form/Form.js index 2a0da84..2e19411 100644 --- a/src/components/ui/form/Form.js +++ b/src/components/ui/form/Form.js @@ -1,26 +1,26 @@ -import React, { Component, PropTypes } from 'react'; +// @flow +import React, { Component } from 'react'; import classNames from 'classnames'; import logger from 'services/logger'; -import FormModel from './FormModel'; import styles from './form.scss'; -export default class Form extends Component { - static displayName = 'Form'; +import type FormModel from './FormModel'; - static propTypes = { - id: PropTypes.string, // and id, that uniquely identifies form contents - isLoading: PropTypes.bool, - form: PropTypes.instanceOf(FormModel), - onSubmit: PropTypes.func, - onInvalid: PropTypes.func, - children: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.node), - PropTypes.node - ]) - }; +type Props = { + id: string, + isLoading: bool, + form?: FormModel, + onSubmit: Function, + onInvalid: (errors: {[errorKey: string]: string}) => void, + children: * +}; +type InputElement = HTMLInputElement|HTMLTextAreaElement; + +export default class Form extends Component { + props: Props; static defaultProps = { id: 'default', @@ -34,13 +34,15 @@ export default class Form extends Component { isLoading: this.props.isLoading || false }; + formEl: ?HTMLFormElement; + componentWillMount() { if (this.props.form) { this.props.form.addLoadingListener(this.onLoading); } } - componentWillReceiveProps(nextProps) { + componentWillReceiveProps(nextProps: Props) { if (nextProps.id !== this.props.id) { this.setState({ isTouched: false @@ -55,9 +57,13 @@ export default class Form extends Component { }); } - if (nextProps.form && this.props.form && nextProps.form !== this.props.form) { + const nextForm = nextProps.form; + if (nextForm + && this.props.form + && nextForm !== this.props.form + ) { this.props.form.removeLoadingListener(this.onLoading); - nextProps.form.addLoadingListener(this.onLoading); + nextForm.addLoadingListener(this.onLoading); } } @@ -80,6 +86,7 @@ export default class Form extends Component { } )} onSubmit={this.onFormSubmit} + ref={(el: ?HTMLFormElement) => this.formEl = el} noValidate > {this.props.children} @@ -87,25 +94,32 @@ export default class Form extends Component { ); } - onFormSubmit = (event) => { - event.preventDefault(); - + submit() { if (!this.state.isTouched) { this.setState({ isTouched: true }); } - const form = event.currentTarget; + const form = this.formEl; + + if (!form) { + return; + } if (form.checkValidity()) { - this.props.onSubmit(); + Promise.resolve(this.props.onSubmit( + this.props.form ? this.props.form : new FormData(form) + )) + .catch((errors: {[key: string]: string}) => { + this.setErrors(errors); + }); } else { const invalidEls = form.querySelectorAll(':invalid'); const errors = {}; invalidEls[0].focus(); // focus on first error - Array.from(invalidEls).reduce((errors, el) => { + Array.from(invalidEls).reduce((errors, el: InputElement) => { if (!el.name) { logger.warn('Found an element without name', {el}); @@ -124,10 +138,20 @@ export default class Form extends Component { return errors; }, errors); - this.props.form && this.props.form.setErrors(errors); - this.props.onInvalid(errors); + this.setErrors(errors); } + } + + setErrors(errors: {[key: string]: string}) { + this.props.form && this.props.form.setErrors(errors); + this.props.onInvalid(errors); + } + + onFormSubmit = (event: Event) => { + event.preventDefault(); + + this.submit(); }; - onLoading = (isLoading) => this.setState({isLoading}); + onLoading = (isLoading: bool) => this.setState({isLoading}); } diff --git a/src/components/ui/form/form.scss b/src/components/ui/form/form.scss index 7e0e799..41a3b21 100644 --- a/src/components/ui/form/form.scss +++ b/src/components/ui/form/form.scss @@ -284,7 +284,8 @@ } [type="submit"] { - background: url('./images/loader_button.gif') #95a5a6 center center; + // TODO: duplicate of .loading from components/ui/buttons + background: url('./images/loader_button.gif') #95a5a6 center center !important; cursor: default; color: #fff; diff --git a/src/components/user/reducer.js b/src/components/user/reducer.js index e17d533..d95751f 100644 --- a/src/components/user/reducer.js +++ b/src/components/user/reducer.js @@ -11,13 +11,13 @@ export type User = {| lang: string, isGuest: bool, isActive: bool, + isOtpEnabled: bool, passwordChangedAt: ?number, hasMojangUsernameCollision: bool, maskedEmail?: string, shouldAcceptRules?: bool, |}; - const defaults: User = { id: null, uuid: null, @@ -30,6 +30,7 @@ const defaults: User = { avatar: '', lang: '', isActive: false, + isOtpEnabled: false, shouldAcceptRules: false, // whether user need to review updated rules passwordChangedAt: null, hasMojangUsernameCollision: false, diff --git a/src/pages/profile/MultiFactorAuthPage.js b/src/pages/profile/MultiFactorAuthPage.js index a38afbc..b0d1fc6 100644 --- a/src/pages/profile/MultiFactorAuthPage.js +++ b/src/pages/profile/MultiFactorAuthPage.js @@ -1,22 +1,21 @@ +// @flow import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import MultiFactorAuth from 'components/profile/multiFactorAuth'; +import MultiFactorAuth, { MfaStep } from 'components/profile/multiFactorAuth'; -import accounts from 'services/api/accounts'; +import type { FormModel } from 'components/ui/form'; class MultiFactorAuthPage extends Component { - static propTypes = { - email: PropTypes.string.isRequired, - lang: PropTypes.string.isRequired, - history: PropTypes.shape({ - push: PropTypes.func - }).isRequired, - match: PropTypes.shape({ - params: PropTypes.shape({ - step: PropTypes.oneOf(['1', '2', '3']) - }) - }) + props: { + history: { + push: (string) => void + }, + match: { + params: { + step?: '1'|'2'|'3' + } + } }; static contextTypes = { @@ -34,71 +33,32 @@ class MultiFactorAuthPage extends Component { } render() { - const {step = '1'} = this.props.match.params; + const step = (parseInt(this.props.match.params.step, 10) || 1) - 1; return ( ); } - onChangeStep = (step) => { - this.props.history.push(`/profile/mfa/step${++step}`); + onChangeStep = (step: MfaStep) => { + this.props.history.push(`/profile/mfa/step${step + 1}`); }; - onSubmit = (step, form) => { + onSubmit = (form: FormModel, sendData: () => Promise<*>) => { return this.context.onSubmit({ form, - sendData: () => { - const data = form.serialize(); - - switch (step) { - case 0: - return accounts.requestEmailChange(data).catch(handleErrors()); - case 1: - return accounts.setNewEmail(data).catch(handleErrors('/profile/change-email')); - case 2: - return accounts.confirmNewEmail(data).catch(handleErrors('/profile/change-email')); - default: - throw new Error(`Unsupported step ${step}`); - } - } - }).then(() => { - step > 1 && this.context.goToProfile(); + sendData }); }; -} -function handleErrors(repeatUrl) { - return (resp) => { - if (resp.errors) { - if (resp.errors.key) { - resp.errors.key = { - type: resp.errors.key, - payload: {} - }; - - if (['error.key_not_exists', 'error.key_expire'].includes(resp.errors.key.type) && repeatUrl) { - Object.assign(resp.errors.key.payload, { - repeatUrl - }); - } - } - } - - return Promise.reject(resp); + onComplete = () => { + this.context.goToProfile(); }; } -import { connect } from 'react-redux'; - -export default connect((state) => ({ - email: state.user.email, - lang: state.user.lang -}), { -})(MultiFactorAuthPage); +export default MultiFactorAuthPage; diff --git a/src/pages/profile/ProfilePage.js b/src/pages/profile/ProfilePage.js index a24a147..3b9a8b7 100644 --- a/src/pages/profile/ProfilePage.js +++ b/src/pages/profile/ProfilePage.js @@ -5,15 +5,13 @@ import PropTypes from 'prop-types'; import { Route, Switch, Redirect } from 'react-router-dom'; import logger from 'services/logger'; -import { browserHistory } from 'services/history'; +import { FooterMenu } from 'components/footerMenu'; import Profile from 'components/profile/Profile'; import ChangePasswordPage from 'pages/profile/ChangePasswordPage'; import ChangeUsernamePage from 'pages/profile/ChangeUsernamePage'; import ChangeEmailPage from 'pages/profile/ChangeEmailPage'; import MultiFactorAuthPage from 'pages/profile/MultiFactorAuthPage'; -import { FooterMenu } from 'components/footerMenu'; - import styles from './profile.scss'; import type { FormModel } from 'components/ui/form'; @@ -67,7 +65,10 @@ import PasswordRequestForm from 'components/profile/passwordRequestForm/Password export default connect(null, { fetchUserData, - onSubmit: ({form, sendData}) => (dispatch) => { + onSubmit: ({form, sendData}: { + form: FormModel, + sendData: () => Promise<*> + }) => (dispatch) => { form.beginLoading(); return sendData() .catch((resp) => { @@ -76,7 +77,7 @@ export default connect(null, { // prevalidate user input, because requestPassword popup will block the // entire form from input, so it must be valid if (resp.errors) { - Reflect.deleteProperty(resp.errors, 'password'); + delete resp.errors.password; if (resp.errors.email && resp.data && resp.data.canRepeatIn) { resp.errors.email = { @@ -92,10 +93,13 @@ export default connect(null, { return Promise.reject(resp); } - return Promise.resolve({requirePassword}); + if (requirePassword) { + return requestPassword(form); + } } + + return Promise.reject(resp); }) - .then((resp) => !resp.requirePassword || requestPassword(form)) .catch((resp) => { if (!resp || !resp.errors) { logger.warn('Unexpected profile editing error', { diff --git a/src/services/api/mfa.js b/src/services/api/mfa.js index 51735dc..dd47716 100644 --- a/src/services/api/mfa.js +++ b/src/services/api/mfa.js @@ -5,5 +5,21 @@ import type { Resp } from 'services/request'; export default { getSecret(): Promise> { return request.get('/api/two-factor-auth'); + }, + + enable(data: {totp: string, password?: string}): Promise> { + return request.post('/api/two-factor-auth', { + token: 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); + }); } }; diff --git a/src/services/errorsDict/errorsDict.intl.json b/src/services/errorsDict/errorsDict.intl.json index 362f60f..fa32e5b 100644 --- a/src/services/errorsDict/errorsDict.intl.json +++ b/src/services/errorsDict/errorsDict.intl.json @@ -22,6 +22,9 @@ "passwordTooShort": "Your password should be at least 8 characters length", "passwordsDoesNotMatch": "The passwords does not match", "rulesAgreementRequired": "You must accept rules in order to create an account", + "totpRequired": "Please, enter the code", + "totpIncorrect": "The code is incorrect", + "mfaAlreadyEnabled": "The two factor auth is already enabled", "keyRequired": "Please, enter an activation key", "keyNotExists": "The key is incorrect or has expired.", "doYouWantRequestKey": "Do you want to request a new key?", diff --git a/src/services/errorsDict/errorsDict.js b/src/services/errorsDict/errorsDict.js index d3e74cc..1171b3d 100644 --- a/src/services/errorsDict/errorsDict.js +++ b/src/services/errorsDict/errorsDict.js @@ -50,6 +50,10 @@ const errorsMap = { ), + 'error.totp_required': () => , + 'error.totp_incorrect': () => , + 'error.otp_already_enabled': () => , + 'error.rePassword_required': () => , 'error.password_too_short': () => , 'error.rePassword_does_not_match': () => ,