diff --git a/src/components/MeasureHeight.js b/src/components/MeasureHeight.js index e1b3023..e42509e 100644 --- a/src/components/MeasureHeight.js +++ b/src/components/MeasureHeight.js @@ -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
; + return
this.el = el} />; } - measure() { + measure = debounce(() => { rAF(() => this.props.onMeasure(this.el.offsetHeight)); - } + }); } diff --git a/src/components/profile/multiFactorAuth/MultiFactorAuth.intl.json b/src/components/profile/multiFactorAuth/MultiFactorAuth.intl.json new file mode 100644 index 0000000..8d6107c --- /dev/null +++ b/src/components/profile/multiFactorAuth/MultiFactorAuth.intl.json @@ -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" +} diff --git a/src/components/profile/multiFactorAuth/MultiFactorAuth.js b/src/components/profile/multiFactorAuth/MultiFactorAuth.js new file mode 100644 index 0000000..2989984 --- /dev/null +++ b/src/components/profile/multiFactorAuth/MultiFactorAuth.js @@ -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 ( +
this.forceUpdate()} + > +
+ + +
+
+ + {(pageTitle) => ( +

+ + {pageTitle} +

+ )} +
+ +
+

+ +

+
+
+
+ +
+ +
+ +
+ {this.renderStepForms()} + +
+ + {this.isLastStep() || 1 ? null : ( +
+ + + +
+ )} +
+
+ ); + } + + renderStepForms() { + const {activeStep} = this.state; + + const steps = [ + () => , + () => , + () => ( + + ) + ]; + + return ( + + {steps.map((renderStep) => renderStep())} + + ); + } + + 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); + // } + // }); + }; +} diff --git a/src/components/profile/multiFactorAuth/confirmation/Confirmation.js b/src/components/profile/multiFactorAuth/confirmation/Confirmation.js new file mode 100644 index 0000000..b034bc7 --- /dev/null +++ b/src/components/profile/multiFactorAuth/confirmation/Confirmation.js @@ -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 ( +
+
+

+ +

+
+ +
+ +
+
+ ); +} diff --git a/src/components/profile/multiFactorAuth/confirmation/index.js b/src/components/profile/multiFactorAuth/confirmation/index.js new file mode 100644 index 0000000..7a16691 --- /dev/null +++ b/src/components/profile/multiFactorAuth/confirmation/index.js @@ -0,0 +1,2 @@ +// @flow +export { default } from './Confirmation'; diff --git a/src/components/profile/multiFactorAuth/index.js b/src/components/profile/multiFactorAuth/index.js new file mode 100644 index 0000000..a924b0d --- /dev/null +++ b/src/components/profile/multiFactorAuth/index.js @@ -0,0 +1 @@ +export { default } from './MultiFactorAuth'; diff --git a/src/components/profile/multiFactorAuth/instructions/Instructions.js b/src/components/profile/multiFactorAuth/instructions/Instructions.js new file mode 100644 index 0000000..ea10505 --- /dev/null +++ b/src/components/profile/multiFactorAuth/instructions/Instructions.js @@ -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 ( +
+
+

+ +

+
+ +
+
+
+ this.onChangeOs(event, 'android')} + /> + this.onChangeOs(event, 'ios')} + /> + this.onChangeOs(event, 'windows')} + /> +
+ +
+ {activeOs ? ( + + ) : null} +
+
+
+
+ ); + } + + onChangeOs(event: MouseEvent, osName: 'android'|'ios'|'windows') { + event.preventDefault(); + + this.setState({ + activeOs: osName + }); + } +} diff --git a/src/components/profile/multiFactorAuth/instructions/OsInstruction.js b/src/components/profile/multiFactorAuth/instructions/OsInstruction.js new file mode 100644 index 0000000..105a161 --- /dev/null +++ b/src/components/profile/multiFactorAuth/instructions/OsInstruction.js @@ -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 ( +
+

+ +

+ + + +
+ + + +
+
+ ); +} diff --git a/src/components/profile/multiFactorAuth/instructions/OsTile.js b/src/components/profile/multiFactorAuth/instructions/OsTile.js new file mode 100644 index 0000000..a17fe31 --- /dev/null +++ b/src/components/profile/multiFactorAuth/instructions/OsTile.js @@ -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 ( +
+ {label} +
{label}
+
+ ); +} diff --git a/src/components/profile/multiFactorAuth/instructions/images/android.svg b/src/components/profile/multiFactorAuth/instructions/images/android.svg new file mode 100644 index 0000000..5f8d89c --- /dev/null +++ b/src/components/profile/multiFactorAuth/instructions/images/android.svg @@ -0,0 +1 @@ + diff --git a/src/components/profile/multiFactorAuth/instructions/images/apple.svg b/src/components/profile/multiFactorAuth/instructions/images/apple.svg new file mode 100644 index 0000000..2bd9c37 --- /dev/null +++ b/src/components/profile/multiFactorAuth/instructions/images/apple.svg @@ -0,0 +1 @@ + diff --git a/src/components/profile/multiFactorAuth/instructions/images/windows.svg b/src/components/profile/multiFactorAuth/instructions/images/windows.svg new file mode 100644 index 0000000..d0b2109 --- /dev/null +++ b/src/components/profile/multiFactorAuth/instructions/images/windows.svg @@ -0,0 +1 @@ + diff --git a/src/components/profile/multiFactorAuth/instructions/index.js b/src/components/profile/multiFactorAuth/instructions/index.js new file mode 100644 index 0000000..064bd11 --- /dev/null +++ b/src/components/profile/multiFactorAuth/instructions/index.js @@ -0,0 +1,2 @@ +// @flow +export { default } from './Instructions'; diff --git a/src/components/profile/multiFactorAuth/instructions/instructions.scss b/src/components/profile/multiFactorAuth/instructions/instructions.scss new file mode 100644 index 0000000..b721ab8 --- /dev/null +++ b/src/components/profile/multiFactorAuth/instructions/instructions.scss @@ -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; +} diff --git a/src/components/profile/multiFactorAuth/keyForm/KeyForm.js b/src/components/profile/multiFactorAuth/keyForm/KeyForm.js new file mode 100644 index 0000000..9c9a3ae --- /dev/null +++ b/src/components/profile/multiFactorAuth/keyForm/KeyForm.js @@ -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 ( +
+
+

+ +

+
+ +
+
+ {key} +
+
+ +
+

+

+ +
+ +

+
+ +
+
+ {key} +
+
+ +
+

+ +

+
+
+ ); +} diff --git a/src/components/profile/multiFactorAuth/keyForm/index.js b/src/components/profile/multiFactorAuth/keyForm/index.js new file mode 100644 index 0000000..8202f84 --- /dev/null +++ b/src/components/profile/multiFactorAuth/keyForm/index.js @@ -0,0 +1,2 @@ +// @flow +export { default } from './KeyForm'; diff --git a/src/components/profile/multiFactorAuth/keyForm/key-form.scss b/src/components/profile/multiFactorAuth/keyForm/key-form.scss new file mode 100644 index 0000000..23ecb3b --- /dev/null +++ b/src/components/profile/multiFactorAuth/keyForm/key-form.scss @@ -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; +} diff --git a/src/components/profile/multiFactorAuth/mfa.scss b/src/components/profile/multiFactorAuth/mfa.scss new file mode 100644 index 0000000..5f54041 --- /dev/null +++ b/src/components/profile/multiFactorAuth/mfa.scss @@ -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; +} diff --git a/src/components/ui/motion/ScrollMotion.js b/src/components/ui/motion/ScrollMotion.js new file mode 100644 index 0000000..5a95e20 --- /dev/null +++ b/src/components/ui/motion/ScrollMotion.js @@ -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 ( + + {(interpolatingStyle: {height: number, transform: string}) => ( +
+
+ {React.Children.map(children, (child, index) => ( + + {child} + + ))} +
+
+ )} +
+ ); + } + + onStepMeasure(step: number) { + return (height: number) => this.setState({ + [`step${step}Height`]: height + }); + } +} diff --git a/src/components/ui/motion/index.js b/src/components/ui/motion/index.js new file mode 100644 index 0000000..62e7144 --- /dev/null +++ b/src/components/ui/motion/index.js @@ -0,0 +1,2 @@ +// @flow +export { default as ScrollMotion } from './ScrollMotion'; diff --git a/src/components/ui/motion/scroll-motion.scss b/src/components/ui/motion/scroll-motion.scss new file mode 100644 index 0000000..279fdcd --- /dev/null +++ b/src/components/ui/motion/scroll-motion.scss @@ -0,0 +1,10 @@ +.container { + white-space: nowrap; +} + +.item { + display: inline-block; + white-space: normal; + vertical-align: top; + max-width: 100%; +} diff --git a/src/components/ui/stepper/Stepper.js b/src/components/ui/stepper/Stepper.js new file mode 100644 index 0000000..ceca761 --- /dev/null +++ b/src/components/ui/stepper/Stepper.js @@ -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 ( +
+ {(new Array(totalSteps)).fill(0).map((_, step) => ( +
+ ))} +
+ ); +}; diff --git a/src/components/ui/stepper/index.js b/src/components/ui/stepper/index.js new file mode 100644 index 0000000..2fb2a1b --- /dev/null +++ b/src/components/ui/stepper/index.js @@ -0,0 +1 @@ +export { default } from './Stepper'; diff --git a/src/components/ui/stepper/stepper.scss b/src/components/ui/stepper/stepper.scss new file mode 100644 index 0000000..08530e6 --- /dev/null +++ b/src/components/ui/stepper/stepper.scss @@ -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; + } +} diff --git a/src/functions.js b/src/functions.js index 68a9595..3441e73 100644 --- a/src/functions.js +++ b/src/functions.js @@ -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): 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); } diff --git a/src/pages/profile/MultiFactorAuthPage.js b/src/pages/profile/MultiFactorAuthPage.js new file mode 100644 index 0000000..d97a93a --- /dev/null +++ b/src/pages/profile/MultiFactorAuthPage.js @@ -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 ( + + ); + } + + 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); diff --git a/src/pages/profile/ProfilePage.js b/src/pages/profile/ProfilePage.js index 020d647..544b467 100644 --- a/src/pages/profile/ProfilePage.js +++ b/src/pages/profile/ProfilePage.js @@ -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 (
+