#305: fully connect mfa ui with backend

This commit is contained in:
SleepWalker 2017-08-20 18:45:21 +03:00
parent b98be6b737
commit 42399ef9bf
15 changed files with 275 additions and 274 deletions

View File

@ -7,6 +7,7 @@
"projectRules": "project rules",
"changedAt": "Changed {at}",
"disabled": "Disabled",
"enabled": "Enabled",
"nickname": "Nickname",
"password": "Password",
"twoFactorAuth": "Two factor auth"

View File

@ -91,8 +91,13 @@ class Profile extends Component {
/>
<ProfileField
link="/profile/mfa"
label={<Message {...messages.twoFactorAuth} />}
value={<Message {...messages.disabled} />}
value={user.isOtpEnabled ? (
<Message {...messages.enabled} />
) : (
<Message {...messages.disabled} />
)}
/>
<ProfileField

View File

@ -4,12 +4,12 @@ import React, { Component } from 'react';
import { FormattedMessage as Message } from 'react-intl';
import Helmet from 'react-helmet';
import { Button, Form, FormModel } from 'components/ui/form';
import { Button, FormModel } from 'components/ui/form';
import { BackButton } from 'components/profile/ProfileForm';
import styles from 'components/profile/profileForm.scss';
import helpLinks from 'components/auth/helpLinks.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';
@ -17,43 +17,43 @@ 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,
lang: string,
email: string,
stepForm: FormModel,
onSubmit: Function,
step: 0|1|2,
code: string
confirmationForm: FormModel,
onSubmit: (form: FormModel, sendData: () => 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 (
<Form form={form}
onSubmit={this.onFormSubmit}
isLoading={isLoading}
onInvalid={() => this.forceUpdate()}
>
<div className={styles.contentWithBackButton}>
<BackButton />
<div className={styles.contentWithBackButton}>
<BackButton />
<div className={styles.form}>
<div className={styles.formBody}>
<Message {...messages.mfaTitle}>
{(pageTitle) => (
<h3 className={styles.title}>
<Helmet title={pageTitle} />
{pageTitle}
</h3>
)}
</Message>
<div className={styles.form}>
<div className={styles.formBody}>
<Message {...messages.mfaTitle}>
{(pageTitle) => (
<h3 className={styles.title}>
<Helmet title={pageTitle} />
{pageTitle}
</h3>
)}
</Message>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.mfaDescription} />
</p>
</div>
<div className={styles.formRow}>
<p className={styles.description}>
<Message {...messages.mfaDescription} />
</p>
</div>
</div>
<div className={styles.stepper}>
<Stepper totalSteps={STEPS_TOTAL} activeStep={activeStep} />
</div>
<div className={styles.form}>
{this.renderStepForms()}
<Button
color="green"
type="submit"
block
label={buttonLabel}
/>
</div>
{this.isLastStep() || 1 ? null : (
<div className={helpLinks.helpLinks}>
<a href="#" onClick={this.onSwitchStep}>
<Message {...messages.alreadyReceivedCode} />
</a>
</div>
)}
</div>
</Form>
<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;
const steps = [
() => <Instructions key="step1" />,
() => (
<KeyForm key="step2"
secret={secret}
qrCodeSrc={qrCodeSrc}
/>
),
() => (
<Confirmation key="step3"
form={this.props.stepForm}
isActiveStep={activeStep === 2}
onCodeInput={this.onCodeInput}
/>
)
];
return (
<ScrollMotion activeStep={activeStep}>
{steps.map((renderStep) => renderStep())}
{[
<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>
);
}
@ -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}));
};
}

View File

@ -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 (
<div className={profileForm.formBody}>
<div className={profileForm.formRow}>
<p className={profileForm.description}>
<Message {...messages.enterCodeFromApp} />
</p>
</div>
<Form form={form}
onSubmit={onSubmit}
onInvalid={onInvalid}
ref={formRef}
>
<div className={profileForm.formBody}>
<div className={profileForm.formRow}>
<p className={profileForm.description}>
<Message {...messages.enterCodeFromApp} />
</p>
</div>
<div className={profileForm.formRow}>
<Input {...form.bindField('key')}
required={isActiveStep}
onChange={onCodeInput}
autoComplete="off"
skin="light"
color="violet"
placeholder={messages.codePlaceholder}
/>
<div className={profileForm.formRow}>
<Input {...form.bindField('totp')}
required
autoComplete="off"
skin="light"
placeholder={messages.codePlaceholder}
/>
</div>
</div>
</div>
</Form>
);
}

View File

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

View File

@ -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;
}

View File

@ -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 (
<button className={classNames(buttons[color], {
[buttons.loading]: loading,
[buttons.block]: block,
[buttons.smallButton]: small
}, className)}
{...props}
{...restProps}
>
{label}
{this.formatMessage(label)}
</button>
);
}

View File

@ -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});
}

View File

@ -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;

View File

@ -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,

View File

@ -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 (
<MultiFactorAuth
onSubmit={this.onSubmit}
email={this.props.email}
lang={this.props.lang}
step={step * 1 - 1}
step={step}
onChangeStep={this.onChangeStep}
onComplete={this.onComplete}
/>
);
}
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;

View File

@ -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', {

View File

@ -5,5 +5,21 @@ import type { Resp } from 'services/request';
export default {
getSecret(): Promise<Resp<{qr: string, secret: string, uri: string}>> {
return request.get('/api/two-factor-auth');
},
enable(data: {totp: string, password?: string}): Promise<Resp<*>> {
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);
});
}
};

View File

@ -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?",

View File

@ -50,6 +50,10 @@ const errorsMap = {
</span>
),
'error.totp_required': () => <Message {...messages.totpRequired} />,
'error.totp_incorrect': () => <Message {...messages.totpIncorrect} />,
'error.otp_already_enabled': () => <Message {...messages.mfaAlreadyEnabled} />,
'error.rePassword_required': () => <Message {...messages.rePasswordRequired} />,
'error.password_too_short': () => <Message {...messages.passwordTooShort} />,
'error.rePassword_does_not_match': () => <Message {...messages.passwordsDoesNotMatch} />,