mirror of
https://github.com/elyby/accounts-frontend.git
synced 2024-12-27 15:30:37 +05:30
#305: initial mfa forms layouts
This commit is contained in:
parent
4a6dcda0e9
commit
a8ae0e0c05
@ -1,7 +1,7 @@
|
||||
import React, { PureComponent, PropTypes } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
// @flow
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import { omit, rAF } from 'functions';
|
||||
import { omit, rAF, debounce } from 'functions';
|
||||
|
||||
/**
|
||||
* MeasureHeight is a component that allows you to measure the height of elements wrapped.
|
||||
@ -24,37 +24,40 @@ import { omit, rAF } from 'functions';
|
||||
*/
|
||||
|
||||
export default class MeasureHeight extends PureComponent {
|
||||
static displayName = 'MeasureHeight';
|
||||
static propTypes = {
|
||||
shouldMeasure: PropTypes.func,
|
||||
onMeasure: PropTypes.func,
|
||||
state: PropTypes.any
|
||||
props: {
|
||||
shouldMeasure: (prevState: any, newState: any) => bool,
|
||||
onMeasure: (height: number) => void,
|
||||
state: any
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
shouldMeasure: (prevState, newState) => prevState !== newState,
|
||||
onMeasure: () => null
|
||||
shouldMeasure: (prevState: any, newState: any) => prevState !== newState,
|
||||
onMeasure: (height) => {} // eslint-disable-line
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.el = ReactDOM.findDOMNode(this);
|
||||
el: HTMLDivElement;
|
||||
|
||||
componentDidMount() {
|
||||
this.measure();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
componentDidUpdate(prevProps: typeof MeasureHeight.prototype.props) {
|
||||
if (this.props.shouldMeasure(prevProps.state, this.props.state)) {
|
||||
this.measure();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const props = omit(this.props, Object.keys(MeasureHeight.propTypes));
|
||||
const props: Object = omit(this.props, [
|
||||
'shouldMeasure',
|
||||
'onMeasure',
|
||||
'state'
|
||||
]);
|
||||
|
||||
return <div {...props} />;
|
||||
return <div {...props} ref={(el: HTMLDivElement) => this.el = el} />;
|
||||
}
|
||||
|
||||
measure() {
|
||||
measure = debounce(() => {
|
||||
rAF(() => this.props.onMeasure(this.el.offsetHeight));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -0,0 +1,19 @@
|
||||
{
|
||||
"mfaTitle": "Two factor authentication",
|
||||
"mfaDescription": "Two factor authentication is an extra layer of security for your account designed to ensure that you're the only person who can access your account, even when your password was compromised. This section will help you to setup two factor auth for your account.",
|
||||
|
||||
"mfaIntroduction": "First of all you need to install one of suggested apps on your phone. This app will generate auth codes for you. Please choose your OS to get corresponding installation links.",
|
||||
"installOnOfTheApps": "Install one of the following apps:",
|
||||
"getAlternativeApps": "Get alternative apps",
|
||||
"theAppIsInstalled": "The app is installed",
|
||||
|
||||
"scanQrCode": "Open your favorit QR scanner app and scan the following QR code:",
|
||||
"or": "OR",
|
||||
"enterKeyManually": "If you can't scan QR code, then enter the secret key manually:",
|
||||
"whenKeyEntered": "Go to the next step, after you will see temporary code in your two-factor auth app.",
|
||||
"ready": "Ready",
|
||||
|
||||
"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"
|
||||
}
|
204
src/components/profile/multiFactorAuth/MultiFactorAuth.js
Normal file
204
src/components/profile/multiFactorAuth/MultiFactorAuth.js
Normal file
@ -0,0 +1,204 @@
|
||||
// @flow
|
||||
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 { 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 Instructions from './instructions';
|
||||
import KeyForm from './keyForm';
|
||||
import Confirmation from './confirmation';
|
||||
import mfaStyles from './mfa.scss';
|
||||
import messages from './MultiFactorAuth.intl.json';
|
||||
|
||||
const STEPS_TOTAL = 3;
|
||||
|
||||
type Props = {
|
||||
onChangeStep: Function,
|
||||
lang: string,
|
||||
email: string,
|
||||
stepForm: FormModel,
|
||||
onSubmit: Function,
|
||||
step: 0|1|2,
|
||||
code: string
|
||||
};
|
||||
|
||||
export default class MultiFactorAuth extends Component {
|
||||
props: Props;
|
||||
|
||||
static defaultProps = {
|
||||
stepForm: new FormModel(),
|
||||
onChangeStep() {},
|
||||
step: 0
|
||||
};
|
||||
|
||||
state: {
|
||||
activeStep: number,
|
||||
code: string,
|
||||
newEmail: ?string
|
||||
} = {
|
||||
activeStep: this.props.step,
|
||||
code: this.props.code || '',
|
||||
newEmail: null
|
||||
};
|
||||
|
||||
componentWillReceiveProps(nextProps: Props) {
|
||||
this.setState({
|
||||
activeStep: typeof nextProps.step === 'number' ? nextProps.step : this.state.activeStep,
|
||||
code: nextProps.code || ''
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {activeStep} = this.state;
|
||||
const form = this.props.stepForm;
|
||||
|
||||
const stepsData = [
|
||||
{
|
||||
buttonLabel: messages.theAppIsInstalled
|
||||
},
|
||||
{
|
||||
buttonLabel: messages.ready
|
||||
},
|
||||
{
|
||||
buttonLabel: messages.enableTwoFactorAuth
|
||||
}
|
||||
];
|
||||
|
||||
const buttonLabel = stepsData[activeStep].buttonLabel;
|
||||
|
||||
return (
|
||||
<Form form={form}
|
||||
onSubmit={this.onFormSubmit}
|
||||
onInvalid={() => this.forceUpdate()}
|
||||
>
|
||||
<div className={styles.contentWithBackButton}>
|
||||
<BackButton />
|
||||
|
||||
<div className={styles.form}>
|
||||
<div className={styles.formBody}>
|
||||
<Message {...messages.mfaTitle}>
|
||||
{(pageTitle) => (
|
||||
<h3 className={styles.violetTitle}>
|
||||
<Helmet title={pageTitle} />
|
||||
{pageTitle}
|
||||
</h3>
|
||||
)}
|
||||
</Message>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.mfaDescription} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={mfaStyles.stepper}>
|
||||
<Stepper totalSteps={STEPS_TOTAL} activeStep={activeStep} />
|
||||
</div>
|
||||
|
||||
<div className={styles.form}>
|
||||
{this.renderStepForms()}
|
||||
|
||||
<Button
|
||||
color="violet"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
renderStepForms() {
|
||||
const {activeStep} = this.state;
|
||||
|
||||
const steps = [
|
||||
() => <Instructions key="step1" />,
|
||||
() => <KeyForm key="step2" />,
|
||||
() => (
|
||||
<Confirmation key="step3"
|
||||
form={this.props.stepForm}
|
||||
isActiveStep={activeStep === 2}
|
||||
onCodeInput={this.onCodeInput}
|
||||
/>
|
||||
)
|
||||
];
|
||||
|
||||
return (
|
||||
<ScrollMotion activeStep={activeStep}>
|
||||
{steps.map((renderStep) => renderStep())}
|
||||
</ScrollMotion>
|
||||
);
|
||||
}
|
||||
|
||||
nextStep() {
|
||||
const {activeStep} = this.state;
|
||||
const nextStep = activeStep + 1;
|
||||
const newEmail = null;
|
||||
|
||||
if (nextStep < STEPS_TOTAL) {
|
||||
this.setState({
|
||||
activeStep: nextStep,
|
||||
newEmail
|
||||
});
|
||||
|
||||
this.props.onChangeStep(nextStep);
|
||||
}
|
||||
}
|
||||
|
||||
isLastStep() {
|
||||
return this.state.activeStep + 1 === STEPS_TOTAL;
|
||||
}
|
||||
|
||||
onSwitchStep = (event: Event) => {
|
||||
event.preventDefault();
|
||||
|
||||
this.nextStep();
|
||||
};
|
||||
|
||||
onCodeInput = (event: {target: HTMLInputElement}) => {
|
||||
const {value} = event.target;
|
||||
|
||||
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);
|
||||
// }
|
||||
// });
|
||||
};
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
|
||||
import { Input, 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
|
||||
}: {
|
||||
form: FormModel,
|
||||
isActiveStep: bool,
|
||||
onCodeInput: (event: Event & {target: HTMLInputElement}) => void
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,2 @@
|
||||
// @flow
|
||||
export { default } from './Confirmation';
|
1
src/components/profile/multiFactorAuth/index.js
Normal file
1
src/components/profile/multiFactorAuth/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './MultiFactorAuth';
|
@ -0,0 +1,82 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import profileForm from 'components/profile/profileForm.scss';
|
||||
import messages from '../MultiFactorAuth.intl.json';
|
||||
|
||||
import OsInstruction from './OsInstruction';
|
||||
import OsTile from './OsTile';
|
||||
import styles from './instructions.scss';
|
||||
import androidLogo from './images/android.svg';
|
||||
import appleLogo from './images/apple.svg';
|
||||
import windowsLogo from './images/windows.svg';
|
||||
|
||||
export default class Instructions extends Component {
|
||||
state: {
|
||||
activeOs: null|'android'|'ios'|'windows'
|
||||
} = {
|
||||
activeOs: null
|
||||
};
|
||||
|
||||
render() {
|
||||
const {activeOs} = this.state;
|
||||
|
||||
return (
|
||||
<div className={profileForm.formBody}>
|
||||
<div className={profileForm.formRow}>
|
||||
<p className={profileForm.description}>
|
||||
<Message {...messages.mfaIntroduction} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={profileForm.formRow}>
|
||||
<div className={classNames(styles.instructionContainer, {
|
||||
[styles.instructionActive]: !!activeOs
|
||||
})}>
|
||||
<div className={classNames(styles.osList, {
|
||||
[styles.androidActive]: activeOs === 'android',
|
||||
[styles.appleActive]: activeOs === 'ios',
|
||||
[styles.windowsActive]: activeOs === 'windows'
|
||||
})}>
|
||||
<OsTile
|
||||
className={styles.androidTile}
|
||||
logo={androidLogo}
|
||||
label="Google Play"
|
||||
onClick={(event) => this.onChangeOs(event, 'android')}
|
||||
/>
|
||||
<OsTile
|
||||
className={styles.appleTile}
|
||||
logo={appleLogo}
|
||||
label="App Store"
|
||||
onClick={(event) => this.onChangeOs(event, 'ios')}
|
||||
/>
|
||||
<OsTile
|
||||
className={styles.windowsTile}
|
||||
logo={windowsLogo}
|
||||
label="Windows Store"
|
||||
onClick={(event) => this.onChangeOs(event, 'windows')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.osInstructionContainer}>
|
||||
{activeOs ? (
|
||||
<OsInstruction os={activeOs} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onChangeOs(event: MouseEvent, osName: 'android'|'ios'|'windows') {
|
||||
event.preventDefault();
|
||||
|
||||
this.setState({
|
||||
activeOs: osName
|
||||
});
|
||||
}
|
||||
}
|
@ -0,0 +1,60 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
|
||||
import messages from '../MultiFactorAuth.intl.json';
|
||||
import styles from './instructions.scss';
|
||||
|
||||
type OS = 'android'|'ios'|'windows';
|
||||
|
||||
const linksByOs: {[key: OS]: Array<{link: string, label: string}>} = {
|
||||
android: [
|
||||
{
|
||||
link: '',
|
||||
label: 'Google Authenticator'
|
||||
},
|
||||
{
|
||||
link: '',
|
||||
label: 'FreeOTP Authenticator'
|
||||
},
|
||||
{
|
||||
link: '',
|
||||
label: 'TOTP Authenticator'
|
||||
}
|
||||
],
|
||||
ios: [
|
||||
],
|
||||
windows: [
|
||||
]
|
||||
};
|
||||
|
||||
export default function OsInstruction({
|
||||
os
|
||||
}: {
|
||||
os: OS
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.osInstruction}>
|
||||
<h3 className={styles.instructionTitle}>
|
||||
<Message {...messages.installOnOfTheApps} />
|
||||
</h3>
|
||||
|
||||
<ul className={styles.appList}>
|
||||
{linksByOs[os].map((item) => (
|
||||
<li key={item.label}>
|
||||
<a href={item.link}>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className={styles.otherApps}>
|
||||
<a href="">
|
||||
<Message {...messages.getAlternativeApps} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1,27 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import styles from './instructions.scss';
|
||||
|
||||
export default function OsInstruction({
|
||||
className,
|
||||
logo,
|
||||
label,
|
||||
onClick
|
||||
}: {
|
||||
className: string,
|
||||
logo: string,
|
||||
label: string,
|
||||
onClick: (event: MouseEvent) => void
|
||||
}) {
|
||||
return (
|
||||
<div className={classNames(styles.osTile, className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<img className={styles.osLogo} src={logo} alt={label} />
|
||||
<div className={styles.osName}>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2232" height="2500" viewBox="32.163 68.509 203.691 228.155"><path d="M101.885 207.092c7.865 0 14.241 6.376 14.241 14.241v61.09c0 7.865-6.376 14.24-14.241 14.24-7.864 0-14.24-6.375-14.24-14.24v-61.09c0-7.864 6.376-14.24 14.24-14.24z" fill="#369070"/><path d="M69.374 133.645c-.047.54-.088 1.086-.088 1.638v92.557c0 9.954 7.879 17.973 17.66 17.973h94.124c9.782 0 17.661-8.02 17.661-17.973v-92.557c0-.552-.02-1.1-.066-1.638H69.374z" fill="#369070"/><path d="M166.133 207.092c7.865 0 14.241 6.376 14.241 14.241v61.09c0 7.865-6.376 14.24-14.241 14.24-7.864 0-14.24-6.375-14.24-14.24v-61.09c0-7.864 6.376-14.24 14.24-14.24zM46.405 141.882c7.864 0 14.24 6.376 14.24 14.241v61.09c0 7.865-6.376 14.241-14.24 14.241-7.865 0-14.241-6.376-14.241-14.24v-61.09c-.001-7.865 6.375-14.242 14.241-14.242zM221.614 141.882c7.864 0 14.24 6.376 14.24 14.241v61.09c0 7.865-6.376 14.241-14.24 14.241-7.865 0-14.241-6.376-14.241-14.24v-61.09c0-7.865 6.376-14.242 14.241-14.242zM69.79 127.565c.396-28.43 25.21-51.74 57.062-54.812h14.312c31.854 3.073 56.666 26.384 57.062 54.812H69.79z" fill="#369070"/><path d="M74.743 70.009l15.022 26.02M193.276 70.009l-15.023 26.02" fill="none" stroke="#369070" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M114.878 102.087c.012 3.974-3.277 7.205-7.347 7.216-4.068.01-7.376-3.202-7.388-7.176v-.04c-.011-3.975 3.278-7.205 7.347-7.216 4.068-.011 7.376 3.2 7.388 7.176v.04zM169.874 102.087c.012 3.974-3.277 7.205-7.347 7.216-4.068.01-7.376-3.202-7.388-7.176v-.04c-.011-3.975 3.278-7.205 7.347-7.216 4.068-.011 7.376 3.2 7.388 7.176v.04z" fill="#fff"/></svg>
|
After Width: | Height: | Size: 1.6 KiB |
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2038" height="2500" viewBox="0 0 496.255 608.728"><path d="M273.81 52.973C313.806.257 369.41 0 369.41 0s8.271 49.562-31.463 97.306c-42.426 50.98-90.649 42.638-90.649 42.638s-9.055-40.094 26.512-86.971zM252.385 174.662c20.576 0 58.764-28.284 108.471-28.284 85.562 0 119.222 60.883 119.222 60.883s-65.833 33.659-65.833 115.331c0 92.133 82.01 123.885 82.01 123.885s-57.328 161.357-134.762 161.357c-35.565 0-63.215-23.967-100.688-23.967-38.188 0-76.084 24.861-100.766 24.861C89.33 608.73 0 455.666 0 332.628c0-121.052 75.612-184.554 146.533-184.554 46.105 0 81.883 26.588 105.852 26.588z" fill="#cac8c1"/></svg>
|
After Width: | Height: | Size: 655 B |
@ -0,0 +1 @@
|
||||
<svg width="2490" height="2500" viewBox="0 0 256 257" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid"><path d="M0 36.357L104.62 22.11l.045 100.914-104.57.595L0 36.358zm104.57 98.293l.08 101.002L.081 221.275l-.006-87.302 104.494.677zm12.682-114.405L255.968 0v121.74l-138.716 1.1V20.246zM256 135.6l-.033 121.191-138.716-19.578-.194-101.84L256 135.6z" fill="#70a5b1"/></svg>
|
After Width: | Height: | Size: 390 B |
@ -0,0 +1,2 @@
|
||||
// @flow
|
||||
export { default } from './Instructions';
|
@ -0,0 +1,142 @@
|
||||
@import "~components/ui/fonts.scss";
|
||||
|
||||
.instructionContainer {
|
||||
position: relative;
|
||||
min-height: 160px;
|
||||
|
||||
background: #fff;
|
||||
border: 1px #fff solid;
|
||||
|
||||
transition: 0.4s ease;
|
||||
}
|
||||
|
||||
.instructionActive {
|
||||
background: #ebe8e1;
|
||||
border-color: #d8d5ce;
|
||||
}
|
||||
|
||||
.osList {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin: 15px;
|
||||
height: 130px;
|
||||
}
|
||||
|
||||
.osTile {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
transition: 0.2s ease-in;
|
||||
}
|
||||
|
||||
.osLogo {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
height: 90px;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.osName {
|
||||
font-size: 16px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.androidTile {
|
||||
$translateX: 0;
|
||||
|
||||
transform: translateX($translateX) scale(1);
|
||||
|
||||
&:hover {
|
||||
transform: translateX($translateX) scale(1.1);
|
||||
}
|
||||
|
||||
.androidActive & {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.appleActive &,
|
||||
.windowsActive & {
|
||||
transform: translateX($translateX) scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.appleTile {
|
||||
$translateX: 125px;
|
||||
|
||||
transform: translateX($translateX) scale(1);
|
||||
|
||||
&:hover {
|
||||
transform: translateX($translateX) scale(1.1);
|
||||
}
|
||||
|
||||
.appleActive & {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.androidActive &,
|
||||
.windowsActive & {
|
||||
transform: translateX($translateX) scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.windowsTile {
|
||||
$translateX: 232px;
|
||||
|
||||
transform: translateX($translateX) scale(1);
|
||||
|
||||
&:hover {
|
||||
transform: translateX($translateX) scale(1.1);
|
||||
}
|
||||
|
||||
.windowsActive & {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.appleActive &,
|
||||
.androidActive & {
|
||||
transform: translateX($translateX) scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.osInstructionContainer {
|
||||
opacity: 0;
|
||||
transition: 0.4s ease;
|
||||
|
||||
.instructionActive & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.osInstruction {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-left: 100px;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.instructionTitle {
|
||||
font-family: $font-family-title;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.appList {
|
||||
margin: 15px 0;
|
||||
font-size: 14px;
|
||||
|
||||
li {
|
||||
margin: 5px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.otherApps {
|
||||
text-align: right;
|
||||
font-size: 13px;
|
||||
opacity: 0.7;
|
||||
}
|
52
src/components/profile/multiFactorAuth/keyForm/KeyForm.js
Normal file
52
src/components/profile/multiFactorAuth/keyForm/KeyForm.js
Normal file
@ -0,0 +1,52 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
|
||||
import profileForm from 'components/profile/profileForm.scss';
|
||||
import messages from '../MultiFactorAuth.intl.json';
|
||||
|
||||
import styles from './key-form.scss';
|
||||
|
||||
export default function KeyForm() {
|
||||
const key = '123 123 52354 1234';
|
||||
|
||||
return (
|
||||
<div className={profileForm.formBody} key="step2">
|
||||
<div className={profileForm.formRow}>
|
||||
<p className={profileForm.description}>
|
||||
<Message {...messages.scanQrCode} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={profileForm.formRow}>
|
||||
<div className={styles.qrCode}>
|
||||
<img src="//placekitten.com/g/242/242" alt={key} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={profileForm.formRow}>
|
||||
<p className={classNames(styles.manualDescription, profileForm.description)}>
|
||||
<div className={styles.or}>
|
||||
<Message {...messages.or} />
|
||||
</div>
|
||||
<Message {...messages.enterKeyManually} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={profileForm.formRow}>
|
||||
<div className={styles.key}>
|
||||
{key}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={profileForm.formRow}>
|
||||
<p className={profileForm.description}>
|
||||
<Message {...messages.whenKeyEntered} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
2
src/components/profile/multiFactorAuth/keyForm/index.js
Normal file
2
src/components/profile/multiFactorAuth/keyForm/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
// @flow
|
||||
export { default } from './KeyForm';
|
27
src/components/profile/multiFactorAuth/keyForm/key-form.scss
Normal file
27
src/components/profile/multiFactorAuth/keyForm/key-form.scss
Normal file
@ -0,0 +1,27 @@
|
||||
.qrCode {
|
||||
text-align: center;
|
||||
|
||||
img {
|
||||
width: 242px;
|
||||
height: 242px;
|
||||
}
|
||||
}
|
||||
|
||||
.manualDescription {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.or {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
margin-top: -18px;
|
||||
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.key {
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
}
|
13
src/components/profile/multiFactorAuth/mfa.scss
Normal file
13
src/components/profile/multiFactorAuth/mfa.scss
Normal file
@ -0,0 +1,13 @@
|
||||
@import '~components/ui/colors.scss';
|
||||
|
||||
.stepper {
|
||||
width: 35%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.currentAccountEmail {
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
line-height: 1.2;
|
||||
}
|
82
src/components/ui/motion/ScrollMotion.js
Normal file
82
src/components/ui/motion/ScrollMotion.js
Normal file
@ -0,0 +1,82 @@
|
||||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { Motion, spring } from 'react-motion';
|
||||
import MeasureHeight from 'components/MeasureHeight';
|
||||
|
||||
import styles from './scroll-motion.scss';
|
||||
|
||||
export default class ScrollMotion extends Component {
|
||||
props: {
|
||||
activeStep: number,
|
||||
children: ?React.Children
|
||||
};
|
||||
|
||||
state: {
|
||||
version: number
|
||||
} = {
|
||||
version: 0
|
||||
};
|
||||
|
||||
isHeightMeasured: bool;
|
||||
|
||||
componentWillReceiveProps() {
|
||||
// mark this view as dirty to re-measure height
|
||||
this.setState({
|
||||
version: this.state.version + 1
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
activeStep,
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
const {version} = this.state;
|
||||
|
||||
const activeStepHeight = this.state[`step${activeStep}Height`] || 0;
|
||||
|
||||
// a hack to disable height animation on first render
|
||||
const isHeightMeasured = this.isHeightMeasured;
|
||||
this.isHeightMeasured = isHeightMeasured || activeStepHeight > 0;
|
||||
|
||||
const motionStyle = {
|
||||
transform: spring(activeStep * 100, {stiffness: 500, damping: 50, precision: 0.5}),
|
||||
height: isHeightMeasured ? spring(activeStepHeight, {stiffness: 500, damping: 20, precision: 0.5}) : activeStepHeight
|
||||
};
|
||||
|
||||
return (
|
||||
<Motion style={motionStyle}>
|
||||
{(interpolatingStyle: {height: number, transform: string}) => (
|
||||
<div style={{
|
||||
overflow: 'hidden',
|
||||
height: `${interpolatingStyle.height}px`
|
||||
}}>
|
||||
<div className={styles.container} style={{
|
||||
WebkitTransform: `translateX(-${interpolatingStyle.transform}%)`,
|
||||
transform: `translateX(-${interpolatingStyle.transform}%)`
|
||||
}}>
|
||||
{React.Children.map(children, (child, index) => (
|
||||
<MeasureHeight
|
||||
className={styles.item}
|
||||
onMeasure={this.onStepMeasure(index)}
|
||||
state={version}
|
||||
key={index}
|
||||
>
|
||||
{child}
|
||||
</MeasureHeight>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Motion>
|
||||
);
|
||||
}
|
||||
|
||||
onStepMeasure(step: number) {
|
||||
return (height: number) => this.setState({
|
||||
[`step${step}Height`]: height
|
||||
});
|
||||
}
|
||||
}
|
2
src/components/ui/motion/index.js
Normal file
2
src/components/ui/motion/index.js
Normal file
@ -0,0 +1,2 @@
|
||||
// @flow
|
||||
export { default as ScrollMotion } from './ScrollMotion';
|
10
src/components/ui/motion/scroll-motion.scss
Normal file
10
src/components/ui/motion/scroll-motion.scss
Normal file
@ -0,0 +1,10 @@
|
||||
.container {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline-block;
|
||||
white-space: normal;
|
||||
vertical-align: top;
|
||||
max-width: 100%;
|
||||
}
|
24
src/components/ui/stepper/Stepper.js
Normal file
24
src/components/ui/stepper/Stepper.js
Normal file
@ -0,0 +1,24 @@
|
||||
// @flow
|
||||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import styles from './stepper.scss';
|
||||
|
||||
export default function Stepper({
|
||||
totalSteps,
|
||||
activeStep
|
||||
} : {
|
||||
totalSteps: number,
|
||||
activeStep: number
|
||||
}) {
|
||||
return (
|
||||
<div className={styles.steps}>
|
||||
{(new Array(totalSteps)).fill(0).map((_, step) => (
|
||||
<div className={classNames(styles.step, {
|
||||
[styles.activeStep]: step <= activeStep
|
||||
})} key={step} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
1
src/components/ui/stepper/index.js
Normal file
1
src/components/ui/stepper/index.js
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './Stepper';
|
63
src/components/ui/stepper/stepper.scss
Normal file
63
src/components/ui/stepper/stepper.scss
Normal file
@ -0,0 +1,63 @@
|
||||
@import '~components/ui/colors.scss';
|
||||
|
||||
.steps {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.step {
|
||||
position: relative;
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
|
||||
height: 4px;
|
||||
background: #d8d5ce;
|
||||
|
||||
|
||||
&:first-child {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: block;
|
||||
|
||||
position: absolute;
|
||||
height: 4px;
|
||||
left: 0;
|
||||
right: 100%;
|
||||
top: 50%;
|
||||
margin-top: -2px;
|
||||
|
||||
background: #aaa;
|
||||
transition: 0.4s ease 0.1s;
|
||||
}
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: -7px;
|
||||
z-index: 1;
|
||||
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 100%;
|
||||
|
||||
background: #aaa;
|
||||
transition: background 0.4s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.activeStep {
|
||||
&:before {
|
||||
right: 0;
|
||||
transition-delay: 0;
|
||||
}
|
||||
|
||||
&:after {
|
||||
background: $violet;
|
||||
transition-delay: 0.3s;
|
||||
}
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
// @flow
|
||||
let lastId = 0;
|
||||
export function uniqueId(prefix = 'id') {
|
||||
export function uniqueId(prefix: string = 'id'): string {
|
||||
return `${prefix}${++lastId}`;
|
||||
}
|
||||
|
||||
@ -9,7 +10,7 @@ export function uniqueId(prefix = 'id') {
|
||||
*
|
||||
* @return {object}
|
||||
*/
|
||||
export function omit(obj, keys) {
|
||||
export function omit(obj: Object, keys: Array<string>): Object {
|
||||
const newObj = {...obj};
|
||||
|
||||
keys.forEach((key) => {
|
||||
@ -26,7 +27,7 @@ export function omit(obj, keys) {
|
||||
*
|
||||
* @return {Promise}
|
||||
*/
|
||||
export function loadScript(src) {
|
||||
export function loadScript(src: string): Promise<*> {
|
||||
const script = document.createElement('script');
|
||||
|
||||
script.async = true;
|
||||
@ -34,10 +35,12 @@ export function loadScript(src) {
|
||||
script.src = src;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
script.onlaod = resolve;
|
||||
script.onload = resolve;
|
||||
script.onerror = reject;
|
||||
|
||||
document.body.appendChild(script);
|
||||
if (document && document.body) {
|
||||
document.body.appendChild(script);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -45,7 +48,7 @@ export const rAF = window.requestAnimationFrame
|
||||
|| window.mozRequestAnimationFrame
|
||||
|| window.webkitRequestAnimationFrame
|
||||
|| window.msRequestAnimationFrame
|
||||
|| ((cb) => setTimeout(cb, 1000 / 60));
|
||||
|| ((cb: Function) => setTimeout(cb, 1000 / 60));
|
||||
|
||||
/**
|
||||
* Returns a function, that, as long as it continues to be invoked, will not
|
||||
@ -60,7 +63,7 @@ export const rAF = window.requestAnimationFrame
|
||||
* @param {number} [timeout=100] - timeout in ms
|
||||
* @param {bool} [immediate=false] - whether to execute at the beginning
|
||||
*/
|
||||
export debounce from 'debounce';
|
||||
export { default as debounce } from 'debounce';
|
||||
|
||||
/**
|
||||
* @param {string} jwt
|
||||
@ -69,7 +72,7 @@ export debounce from 'debounce';
|
||||
*
|
||||
* @return {object} - decoded jwt payload
|
||||
*/
|
||||
export function getJwtPayload(jwt) {
|
||||
export function getJwtPayload(jwt: string): Object {
|
||||
const parts = (jwt || '').split('.');
|
||||
|
||||
if (parts.length !== 3) {
|
||||
@ -88,9 +91,14 @@ export function getJwtPayload(jwt) {
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
export function getScrollTop() {
|
||||
export function getScrollTop(): number {
|
||||
const doc = document.documentElement;
|
||||
return (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0);
|
||||
|
||||
if (doc) {
|
||||
return (window.pageYOffset || doc.scrollTop) - (doc.clientTop || 0);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -100,12 +108,16 @@ export function getScrollTop() {
|
||||
*/
|
||||
|
||||
const TIME_CONSTANT = 100; // higher numbers - slower animation
|
||||
export function scrollTo(y) {
|
||||
export function scrollTo(y: number) {
|
||||
const start = Date.now();
|
||||
let scrollWasTouched = false;
|
||||
rAF(() => { // wrap in rAF to optimize initial reading of scrollTop
|
||||
const contentHeight = document.documentElement.scrollHeight;
|
||||
const windowHeight = window.innerHeight;
|
||||
if (!document.documentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentHeight = document.documentElement.scrollHeight || 0;
|
||||
const windowHeight: number = window.innerHeight;
|
||||
if (contentHeight < y + windowHeight) {
|
||||
y = contentHeight - windowHeight;
|
||||
}
|
||||
@ -169,6 +181,6 @@ export function restoreScroll() {
|
||||
y = getScrollTop() + top - SCROLL_ANCHOR_OFFSET;
|
||||
}
|
||||
|
||||
scrollTo(y, viewPort);
|
||||
scrollTo(y);
|
||||
}, isFirstScroll ? 200 : 0);
|
||||
}
|
||||
|
105
src/pages/profile/MultiFactorAuthPage.js
Normal file
105
src/pages/profile/MultiFactorAuthPage.js
Normal file
@ -0,0 +1,105 @@
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
|
||||
import MultiFactorAuth from 'components/profile/multiFactorAuth';
|
||||
|
||||
import accounts from 'services/api/accounts';
|
||||
|
||||
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(['step1', 'step2', 'step3']),
|
||||
code: PropTypes.string
|
||||
})
|
||||
})
|
||||
};
|
||||
|
||||
static contextTypes = {
|
||||
onSubmit: PropTypes.func.isRequired,
|
||||
goToProfile: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
componentWillMount() {
|
||||
const step = this.props.match.params.step;
|
||||
|
||||
if (step && !/^step[123]$/.test(step)) {
|
||||
// wrong param value
|
||||
this.props.history.push('/404');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const {step = 'step1', code} = this.props.match.params;
|
||||
|
||||
return (
|
||||
<MultiFactorAuth
|
||||
onSubmit={this.onSubmit}
|
||||
email={this.props.email}
|
||||
lang={this.props.lang}
|
||||
step={step.slice(-1) * 1 - 1}
|
||||
onChangeStep={this.onChangeStep}
|
||||
code={code}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
onChangeStep = (step) => {
|
||||
this.props.history.push(`/profile/mfa/step${++step}`);
|
||||
};
|
||||
|
||||
onSubmit = (step, form) => {
|
||||
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();
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
};
|
||||
}
|
||||
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
export default connect((state) => ({
|
||||
email: state.user.email,
|
||||
lang: state.user.lang
|
||||
}), {
|
||||
})(MultiFactorAuthPage);
|
@ -10,6 +10,7 @@ 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';
|
||||
|
||||
@ -39,6 +40,7 @@ class ProfilePage extends Component {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Switch>
|
||||
<Route path="/profile/mfa/:step?" component={MultiFactorAuthPage} />
|
||||
<Route path="/profile/change-password" component={ChangePasswordPage} />
|
||||
<Route path="/profile/change-username" component={ChangeUsernamePage} />
|
||||
<Route path="/profile/change-email/:step?/:code?" component={ChangeEmailPage} />
|
||||
|
Loading…
Reference in New Issue
Block a user