);
}
renderStepForms() {
const {activeStep, secret, qrCodeSrc} = this.state;
- const steps = [
- () =>
);
}
@@ -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': () => ,