#305: initial mfa forms layouts

This commit is contained in:
SleepWalker 2017-07-22 18:57:38 +03:00
parent 4a6dcda0e9
commit a8ae0e0c05
27 changed files with 1011 additions and 31 deletions

View File

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

View File

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

View 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);
// }
// });
};
}

View File

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

View File

@ -0,0 +1,2 @@
// @flow
export { default } from './Confirmation';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,2 @@
// @flow
export { default } from './Instructions';

View File

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

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

View File

@ -0,0 +1,2 @@
// @flow
export { default } from './KeyForm';

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

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

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

View File

@ -0,0 +1,2 @@
// @flow
export { default as ScrollMotion } from './ScrollMotion';

View File

@ -0,0 +1,10 @@
.container {
white-space: nowrap;
}
.item {
display: inline-block;
white-space: normal;
vertical-align: top;
max-width: 100%;
}

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

View File

@ -0,0 +1 @@
export { default } from './Stepper';

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

View File

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

View 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);

View File

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