Create app namespace for all absolute requires of app modules. Move all packages under packages yarn workspace

This commit is contained in:
SleepWalker
2019-12-07 21:02:00 +02:00
parent d8d2df0702
commit f9d3bb4e20
404 changed files with 758 additions and 742 deletions

View File

@ -0,0 +1,16 @@
import React from 'react';
import Helmet from 'react-helmet';
import { FormattedMessage as Message, MessageDescriptor } from 'react-intl';
export default function AuthTitle({ title }: { title: MessageDescriptor }) {
return (
<Message {...title}>
{msg => (
<span>
{msg}
<Helmet title={msg} />
</span>
)}
</Message>
);
}

View File

@ -0,0 +1,67 @@
/**
* Helps with form fields binding, form serialization and errors rendering
*/
import PropTypes from 'prop-types';
import React from 'react';
import AuthError from 'app/components/auth/authError/AuthError';
import { userShape } from 'app/components/user/User';
import { FormModel } from 'app/components/ui/form';
import { RouteComponentProps } from 'react-router-dom';
export default class BaseAuthBody extends React.Component<
// TODO: this may be converted to generic type RouteComponentProps<T>
RouteComponentProps<{ [key: string]: any }>
> {
static contextTypes = {
clearErrors: PropTypes.func.isRequired,
resolve: PropTypes.func.isRequired,
requestRedraw: PropTypes.func.isRequired,
auth: PropTypes.shape({
error: PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
type: PropTypes.string,
payload: PropTypes.object,
}),
]),
scopes: PropTypes.array,
}).isRequired,
user: userShape,
};
autoFocusField: string | null = '';
componentWillReceiveProps(nextProps, nextContext) {
if (nextContext.auth.error !== this.context.auth.error) {
this.form.setErrors(nextContext.auth.error || {});
}
}
renderErrors() {
const error = this.form.getFirstError();
return error && <AuthError error={error} onClose={this.onClearErrors} />;
}
onFormSubmit() {
this.context.resolve(this.serialize());
}
onClearErrors = this.context.clearErrors;
form = new FormModel({
renderErrors: false,
});
bindField = this.form.bindField.bind(this.form);
serialize() {
return this.form.serialize();
}
autoFocus() {
const fieldId = this.autoFocusField;
fieldId && this.form.focus(fieldId);
}
}

View File

@ -0,0 +1,640 @@
import React from 'react';
import { AccountsState } from 'app/components/accounts';
import { AuthState } from 'app/components/auth';
import { User } from 'app/components/user';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { TransitionMotion, spring } from 'react-motion';
import {
Panel,
PanelBody,
PanelFooter,
PanelHeader,
} from 'app/components/ui/Panel';
import { getLogin } from 'app/components/auth/reducer';
import { Form } from 'app/components/ui/form';
import MeasureHeight from 'app/components/MeasureHeight';
import defaultHelpLinksStyles from 'app/components/auth/helpLinks.scss';
import panelStyles from 'app/components/ui/panel.scss';
import icons from 'app/components/ui/icons.scss';
import authFlow from 'app/services/authFlow';
import { userShape } from 'app/components/user/User';
import * as actions from './actions';
import { RootState } from 'app/reducers';
const opacitySpringConfig = { stiffness: 300, damping: 20 };
const transformSpringConfig = { stiffness: 500, damping: 50, precision: 0.5 };
const changeContextSpringConfig = {
stiffness: 500,
damping: 20,
precision: 0.5,
};
const { helpLinksStyles } = defaultHelpLinksStyles;
type PanelId = string;
/**
* Definition of relation between contexts and panels
*
* Each sub-array is context. Each sub-array item is panel
*
* This definition declares animations between panels:
* - The animation between panels from different contexts will be along Y axe (height toggling)
* - The animation between panels from the same context will be along X axe (sliding)
* - Panel index defines the direction of X transition of both panels
* (e.g. the panel with lower index will slide from left side, and with greater from right side)
*/
const contexts: Array<PanelId[]> = [
['login', 'password', 'forgotPassword', 'mfa', 'recoverPassword'],
['register', 'activation', 'resendActivation'],
['acceptRules'],
['chooseAccount', 'permissions'],
];
// eslint-disable-next-line
if (process.env.NODE_ENV !== 'production') {
// test panel uniquenes between contexts
// TODO: it may be moved to tests in future
contexts.reduce((acc, context) => {
context.forEach(panel => {
if (acc[panel]) {
throw new Error(
`Panel ${panel} is already exists in context ${JSON.stringify(
acc[panel],
)}`,
);
}
acc[panel] = context;
});
return acc;
}, {});
}
type ValidationError =
| string
| {
type: string;
payload: { [key: string]: any };
};
type AnimationProps = {
opacitySpring: number;
transformSpring: number;
};
type AnimationContext = {
key: PanelId;
style: AnimationProps;
data: {
Title: React.ReactElement<any>;
Body: React.ReactElement<any>;
Footer: React.ReactElement<any>;
Links: React.ReactElement<any>;
hasBackButton: boolean | ((props: Props) => boolean);
};
};
type OwnProps = {
Title: React.ReactElement<any>;
Body: React.ReactElement<any>;
Footer: React.ReactElement<any>;
Links: React.ReactElement<any>;
children?: React.ReactElement<any>;
};
interface Props extends OwnProps {
// context props
auth: AuthState;
user: User;
accounts: AccountsState;
setErrors: (errors: { [key: string]: ValidationError }) => void;
clearErrors: () => void;
resolve: () => void;
reject: () => void;
}
type State = {
contextHeight: number;
panelId: PanelId | void;
prevPanelId: PanelId | void;
isHeightDirty: boolean;
forceHeight: 1 | 0;
direction: 'X' | 'Y';
};
class PanelTransition extends React.Component<Props, State> {
static childContextTypes = {
auth: PropTypes.shape({
error: PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
type: PropTypes.string,
payload: PropTypes.object,
}),
]),
login: PropTypes.string,
}),
user: userShape,
accounts: PropTypes.shape({
available: PropTypes.array,
}),
requestRedraw: PropTypes.func,
clearErrors: PropTypes.func,
resolve: PropTypes.func,
reject: PropTypes.func,
};
state: State = {
contextHeight: 0,
panelId: this.props.Body && (this.props.Body.type as any).panelId,
isHeightDirty: false,
forceHeight: 0 as const,
direction: 'X' as const,
prevPanelId: undefined,
};
isHeightMeasured: boolean = false;
wasAutoFocused: boolean = false;
body: null | {
autoFocus: () => void;
onFormSubmit: () => void;
} = null;
timerIds: NodeJS.Timeout[] = []; // this is a list of a probably running timeouts to clean on unmount
getChildContext() {
return {
auth: this.props.auth,
user: this.props.user,
requestRedraw: (): Promise<void> =>
new Promise(resolve =>
this.setState({ isHeightDirty: true }, () => {
this.setState({ isHeightDirty: false });
// wait till transition end
this.timerIds.push(setTimeout(resolve, 200));
}),
),
clearErrors: this.props.clearErrors,
resolve: this.props.resolve,
reject: this.props.reject,
};
}
componentWillReceiveProps(nextProps: Props) {
const nextPanel: PanelId =
nextProps.Body && (nextProps.Body.type as any).panelId;
const prevPanel: PanelId =
this.props.Body && (this.props.Body.type as any).panelId;
if (nextPanel !== prevPanel) {
const direction = this.getDirection(nextPanel, prevPanel);
const forceHeight = direction === 'Y' && nextPanel !== prevPanel ? 1 : 0;
this.props.clearErrors();
this.setState({
direction,
panelId: nextPanel,
prevPanelId: prevPanel,
forceHeight,
});
if (forceHeight) {
this.timerIds.push(
setTimeout(() => {
this.setState({ forceHeight: 0 });
}, 100),
);
}
}
}
componentWillUnmount() {
this.timerIds.forEach(id => clearTimeout(id));
this.timerIds = [];
}
render() {
const { contextHeight, forceHeight } = this.state;
const { Title, Body, Footer, Links } = this.props;
if (this.props.children) {
return this.props.children;
} else if (!Title || !Body || !Footer || !Links) {
throw new Error('Title, Body, Footer and Links are required');
}
const {
panelId,
hasGoBack,
}: {
panelId: PanelId;
hasGoBack: boolean;
} = Body.type as any;
const formHeight = this.state[`formHeight${panelId}`] || 0;
// a hack to disable height animation on first render
const { isHeightMeasured } = this;
this.isHeightMeasured = isHeightMeasured || formHeight > 0;
return (
<TransitionMotion
styles={[
{
key: panelId,
data: { Title, Body, Footer, Links, hasBackButton: hasGoBack },
style: {
transformSpring: spring(0, transformSpringConfig),
opacitySpring: spring(1, opacitySpringConfig),
},
},
{
key: 'common',
style: {
heightSpring: isHeightMeasured
? spring(forceHeight || formHeight, transformSpringConfig)
: formHeight,
switchContextHeightSpring: spring(
forceHeight || contextHeight,
changeContextSpringConfig,
),
},
},
]}
willEnter={this.willEnter}
willLeave={this.willLeave}
>
{items => {
const panels = items.filter(({ key }) => key !== 'common');
const [common] = items.filter(({ key }) => key === 'common');
const contentHeight = {
overflow: 'hidden',
height: forceHeight
? common.style.switchContextHeightSpring
: 'auto',
};
this.tryToAutoFocus(panels.length);
const bodyHeight = {
position: 'relative' as const,
height: `${common.style.heightSpring}px`,
};
return (
<Form
id={panelId}
onSubmit={this.onFormSubmit}
onInvalid={this.onFormInvalid}
isLoading={this.props.auth.isLoading}
>
<Panel>
<PanelHeader>
{panels.map(config => this.getHeader(config))}
</PanelHeader>
<div style={contentHeight}>
<MeasureHeight
state={this.shouldMeasureHeight()}
onMeasure={this.onUpdateContextHeight}
>
<PanelBody>
<div style={bodyHeight}>
{panels.map(config => this.getBody(config))}
</div>
</PanelBody>
<PanelFooter>
{panels.map(config => this.getFooter(config))}
</PanelFooter>
</MeasureHeight>
</div>
</Panel>
<div className={helpLinksStyles}>
{panels.map(config => this.getLinks(config))}
</div>
</Form>
);
}}
</TransitionMotion>
);
}
onFormSubmit = () => {
this.props.clearErrors();
if (this.body) {
this.body.onFormSubmit();
}
};
onFormInvalid = (errors: { [key: string]: ValidationError }) =>
this.props.setErrors(errors);
willEnter = (config: AnimationContext) => this.getTransitionStyles(config);
willLeave = (config: AnimationContext) =>
this.getTransitionStyles(config, { isLeave: true });
/**
* @param {object} config
* @param {string} config.key
* @param {object} [options]
* @param {object} [options.isLeave=false] - true, if this is a leave transition
*
* @returns {object}
*/
getTransitionStyles(
{ key }: AnimationContext,
options: { isLeave?: boolean } = {},
): {
transformSpring: number;
opacitySpring: number;
} {
const { isLeave = false } = options;
const { panelId, prevPanelId } = this.state;
const fromLeft = -1;
const fromRight = 1;
const currentContext = contexts.find(context => context.includes(key));
if (!currentContext) {
throw new Error(`Can not find settings for ${key} panel`);
}
let sign =
prevPanelId &&
panelId &&
currentContext.indexOf(panelId) > currentContext.indexOf(prevPanelId)
? fromRight
: fromLeft;
if (prevPanelId === key) {
sign *= -1;
}
const transform = sign * 100;
return {
transformSpring: isLeave
? spring(transform, transformSpringConfig)
: transform,
opacitySpring: isLeave ? spring(0, opacitySpringConfig) : 1,
};
}
getDirection(next: PanelId, prev: PanelId): 'X' | 'Y' {
const context = contexts.find(item => item.includes(prev));
if (!context) {
throw new Error(`Can not find context for transition ${prev} -> ${next}`);
}
return context.includes(next) ? 'X' : 'Y';
}
onUpdateHeight = (height: number, key: PanelId) => {
const heightKey = `formHeight${key}`;
// @ts-ignore
this.setState({
[heightKey]: height,
});
};
onUpdateContextHeight = (height: number) => {
this.setState({
contextHeight: height,
});
};
onGoBack = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
authFlow.goBack();
};
/**
* Tries to auto focus form fields after transition end
*
* @param {number} length number of panels transitioned
*/
tryToAutoFocus(length: number) {
if (!this.body) {
return;
}
if (length === 1) {
if (!this.wasAutoFocused) {
this.body.autoFocus();
}
this.wasAutoFocused = true;
} else if (this.wasAutoFocused) {
this.wasAutoFocused = false;
}
}
shouldMeasureHeight() {
const errorString = Object.values(this.props.auth.error || {}).reduce(
(acc, item: ValidationError) => {
if (typeof item === 'string') {
return acc + item;
}
return acc + item.type;
},
'',
);
return [
errorString,
this.state.isHeightDirty,
this.props.user.lang,
this.props.accounts.available.length,
].join('');
}
getHeader({ key, style, data }: AnimationContext) {
const { Title } = data;
const { transformSpring } = style;
let { hasBackButton } = data;
if (typeof hasBackButton === 'function') {
hasBackButton = hasBackButton(this.props);
}
const transitionStyle = {
...this.getDefaultTransitionStyles(key, style),
opacity: 1, // reset default
};
const scrollStyle = this.translate(transformSpring, 'Y');
const sideScrollStyle = {
position: 'relative' as const,
zIndex: 2,
...this.translate(-Math.abs(transformSpring)),
};
const backButton = (
<button
style={sideScrollStyle}
className={panelStyles.headerControl}
data-e2e-go-back
type="button"
onClick={this.onGoBack}
>
<span className={icons.arrowLeft} />
</button>
);
return (
<div key={`header/${key}`} style={transitionStyle}>
{hasBackButton ? backButton : null}
<div style={scrollStyle}>{Title}</div>
</div>
);
}
getBody({ key, style, data }: AnimationContext) {
const { Body } = data;
const { transformSpring } = style;
const { direction } = this.state;
let transform: { [key: string]: string } = this.translate(
transformSpring,
direction,
);
let verticalOrigin = 'top';
if (direction === 'Y') {
verticalOrigin = 'bottom';
transform = {};
}
const transitionStyle = {
...this.getDefaultTransitionStyles(key, style),
top: 'auto', // reset default
[verticalOrigin]: 0,
...transform,
};
return (
<MeasureHeight
key={`body/${key}`}
style={transitionStyle}
state={this.shouldMeasureHeight()}
onMeasure={height => this.onUpdateHeight(height, key)}
>
{React.cloneElement(Body, {
ref: body => {
this.body = body;
},
})}
</MeasureHeight>
);
}
getFooter({ key, style, data }: AnimationContext) {
const { Footer } = data;
const transitionStyle = this.getDefaultTransitionStyles(key, style);
return (
<div key={`footer/${key}`} style={transitionStyle}>
{Footer}
</div>
);
}
getLinks({ key, style, data }: AnimationContext) {
const { Links } = data;
const transitionStyle = this.getDefaultTransitionStyles(key, style);
return (
<div key={`links/${key}`} style={transitionStyle}>
{Links}
</div>
);
}
/**
* @param {string} key
* @param {object} style
* @param {number} style.opacitySpring
*
* @returns {object}
*/
getDefaultTransitionStyles(
key: string,
{ opacitySpring }: Readonly<AnimationProps>,
): {
position: 'absolute';
top: number;
left: number;
width: string;
opacity: number;
pointerEvents: 'none' | 'auto';
} {
return {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
opacity: opacitySpring,
pointerEvents: key === this.state.panelId ? 'auto' : 'none',
};
}
translate(value: number, direction: 'X' | 'Y' = 'X', unit: '%' | 'px' = '%') {
return {
WebkitTransform: `translate${direction}(${value}${unit})`,
transform: `translate${direction}(${value}${unit})`,
};
}
}
export default connect(
(state: RootState) => {
const login = getLogin(state);
let user = {
...state.user,
};
if (login) {
user = {
...user,
isGuest: true,
email: '',
username: '',
};
if (/[@.]/.test(login)) {
user.email = login;
} else {
user.username = login;
}
}
return {
user,
accounts: state.accounts, // need this, to re-render height
auth: state.auth,
resolve: authFlow.resolve.bind(authFlow),
reject: authFlow.reject.bind(authFlow),
};
},
{
clearErrors: actions.clearErrors,
setErrors: actions.setErrors,
},
)(PanelTransition);

View File

@ -0,0 +1,17 @@
# How to add new auth panel
To add new panel you need to:
- create panel component at `components/auth/[panelId]`
- add new context in `components/auth/PanelTransition`
- connect component to router in `pages/auth/AuthPage`
- add new state to `services/authFlow` and coresponding test to
`tests/services/authFlow`
- connect state to `authFlow`. Update `services/authFlow/AuthFlow.test` and
`services/authFlow/AuthFlow.functional.test` (the last one for some complex
flow)
- add new actions to `components/auth/actions` and api endpoints to
`services/api`
- whatever else you need
Commit id with example implementation: f4d315c

View File

@ -0,0 +1,46 @@
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage as Message, MessageDescriptor } from 'react-intl';
import { User } from 'app/components/user';
import { userShape } from 'app/components/user/User';
interface Props {
isAvailable?: (context: Context) => boolean;
payload?: { [key: string]: any };
label: MessageDescriptor;
}
export type RejectionLinkProps = Props;
interface Context {
reject: (payload: { [key: string]: any } | undefined) => void;
user: User;
}
function RejectionLink(props: Props, context: Context) {
if (props.isAvailable && !props.isAvailable(context)) {
// TODO: if want to properly support multiple links, we should control
// the dividers ' | ' rendered from factory too
return null;
}
return (
<a
href="#"
onClick={event => {
event.preventDefault();
context.reject(props.payload);
}}
>
<Message {...props.label} />
</a>
);
}
RejectionLink.contextTypes = {
reject: PropTypes.func.isRequired,
user: userShape,
};
export default RejectionLink;

View File

@ -0,0 +1,8 @@
{
"title": "User Agreement",
"accept": "Accept",
"declineAndLogout": "Decline and logout",
"description1": "We have updated our {link}.",
"termsOfService": "terms of service",
"description2": "In order to continue using {name} service, you need to accept them."
}

View File

@ -0,0 +1,16 @@
import factory from '../factory';
import Body from './AcceptRulesBody';
import messages from './AcceptRules.intl.json';
export default factory({
title: messages.title,
body: Body,
footer: {
color: 'darkBlue',
autoFocus: true,
label: messages.accept,
},
links: {
label: messages.declineAndLogout,
},
});

View File

@ -0,0 +1,48 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Link } from 'react-router-dom';
import icons from 'app/components/ui/icons.scss';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import appInfo from 'app/components/auth/appInfo/AppInfo.intl.json';
import styles from './acceptRules.scss';
import messages from './AcceptRules.intl.json';
export default class AcceptRulesBody extends BaseAuthBody {
static displayName = 'AcceptRulesBody';
static panelId = 'acceptRules';
render() {
return (
<div>
{this.renderErrors()}
<div className={styles.security}>
<span className={icons.lock} />
</div>
<p className={styles.descriptionText}>
<Message
{...messages.description1}
values={{
link: (
<Link to="/rules" target="_blank">
<Message {...messages.termsOfService} />
</Link>
),
}}
/>
<br />
<Message
{...messages.description2}
values={{
name: <Message {...appInfo.appName} />,
}}
/>
</p>
</div>
);
}
}

View File

@ -0,0 +1,16 @@
@import '~app/components/ui/colors.scss';
.descriptionText {
font-size: 15px;
line-height: 1.4;
padding-bottom: 8px;
color: #aaa;
}
// TODO: вынести иконки такого типа в какую-то внешнюю структуру?
.security {
color: #fff;
font-size: 90px;
line-height: 1;
margin-bottom: 15px;
}

View File

@ -0,0 +1,194 @@
import sinon from 'sinon';
import expect from 'app/test/unexpected';
import request from 'app/services/request';
import {
setLoadingState,
oAuthValidate,
oAuthComplete,
setClient,
setOAuthRequest,
setScopes,
setOAuthCode,
requirePermissionsAccept,
login,
setLogin,
} from 'app/components/auth/actions';
const oauthData = {
clientId: '',
redirectUrl: '',
responseType: '',
scope: '',
state: '',
};
describe('components/auth/actions', () => {
const dispatch = sinon.stub().named('store.dispatch');
const getState = sinon.stub().named('store.getState');
function callThunk(fn, ...args) {
const thunk = fn(...args);
return thunk(dispatch, getState);
}
function expectDispatchCalls(calls) {
expect(
dispatch,
'to have calls satisfying',
[[setLoadingState(true)]]
.concat(calls)
.concat([[setLoadingState(false)]]),
);
}
beforeEach(() => {
dispatch.reset();
getState.reset();
getState.returns({});
sinon.stub(request, 'get').named('request.get');
sinon.stub(request, 'post').named('request.post');
});
afterEach(() => {
(request.get as any).restore();
(request.post as any).restore();
});
describe('#oAuthValidate()', () => {
let resp;
beforeEach(() => {
resp = {
client: { id: 123 },
oAuth: { state: 123 },
session: {
scopes: ['scopes'],
},
};
(request.get as any).returns(Promise.resolve(resp));
});
it('should send get request to an api', () =>
callThunk(oAuthValidate, oauthData).then(() => {
expect(request.get, 'to have a call satisfying', [
'/api/oauth2/v1/validate',
{},
]);
}));
it('should dispatch setClient, setOAuthRequest and setScopes', () =>
callThunk(oAuthValidate, oauthData).then(() => {
expectDispatchCalls([
[setClient(resp.client)],
[
setOAuthRequest({
...resp.oAuth,
prompt: 'none',
loginHint: undefined,
}),
],
[setScopes(resp.session.scopes)],
]);
}));
});
describe('#oAuthComplete()', () => {
beforeEach(() => {
getState.returns({
auth: {
oauth: oauthData,
},
});
});
it('should post to api/oauth2/complete', () => {
(request.post as any).returns(
Promise.resolve({
redirectUri: '',
}),
);
return callThunk(oAuthComplete).then(() => {
expect(request.post, 'to have a call satisfying', [
'/api/oauth2/v1/complete?client_id=&redirect_uri=&response_type=&description=&scope=&prompt=&login_hint=&state=',
{},
]);
});
});
it('should dispatch setOAuthCode for static_page redirect', () => {
const resp = {
success: true,
redirectUri: 'static_page?code=123&state=',
};
(request.post as any).returns(Promise.resolve(resp));
return callThunk(oAuthComplete).then(() => {
expectDispatchCalls([
[
setOAuthCode({
success: true,
code: '123',
displayCode: false,
}),
],
]);
});
});
it('should resolve to with success false and redirectUri for access_denied', async () => {
const resp = {
statusCode: 401,
error: 'access_denied',
redirectUri: 'redirectUri',
};
(request.post as any).returns(Promise.reject(resp));
const data = await callThunk(oAuthComplete);
expect(data, 'to equal', {
success: false,
redirectUri: 'redirectUri',
});
});
it('should dispatch requirePermissionsAccept if accept_required', () => {
const resp = {
statusCode: 401,
error: 'accept_required',
};
(request.post as any).returns(Promise.reject(resp));
return callThunk(oAuthComplete).catch(error => {
expect(error.acceptRequired, 'to be true');
expectDispatchCalls([[requirePermissionsAccept()]]);
});
});
});
describe('#login()', () => {
describe('when correct login was entered', () => {
beforeEach(() => {
(request.post as any).returns(
Promise.reject({
errors: {
password: 'error.password_required',
},
}),
);
});
it('should set login', () =>
callThunk(login, { login: 'foo' }).then(() => {
expectDispatchCalls([[setLogin('foo')]]);
}));
});
});
});

View File

@ -0,0 +1,658 @@
import { browserHistory } from 'app/services/history';
import logger from 'app/services/logger';
import localStorage from 'app/services/localStorage';
import loader from 'app/services/loader';
import history from 'app/services/history';
import {
updateUser,
acceptRules as userAcceptRules,
} from 'app/components/user/actions';
import { authenticate, logoutAll } from 'app/components/accounts/actions';
import { getActiveAccount } from 'app/components/accounts/reducer';
import {
login as loginEndpoint,
forgotPassword as forgotPasswordEndpoint,
recoverPassword as recoverPasswordEndpoint,
OAuthResponse,
} from 'app/services/api/authentication';
import oauth, { OauthData, Client, Scope } from 'app/services/api/oauth';
import signup from 'app/services/api/signup';
import dispatchBsod from 'app/components/ui/bsod/dispatchBsod';
import { create as createPopup } from 'app/components/ui/popup/actions';
import ContactForm from 'app/components/contact/ContactForm';
import { ThunkAction, Dispatch } from 'app/reducers';
import { getCredentials } from './reducer';
type ValidationError =
| string
| {
type: string;
payload: { [key: string]: any };
};
export { updateUser } from 'app/components/user/actions';
export {
authenticate,
logoutAll as logout,
remove as removeAccount,
activate as activateAccount,
} from 'app/components/accounts/actions';
import { Account } from 'app/components/accounts/reducer';
/**
* Reoutes user to the previous page if it is possible
*
* @param {object} options
* @param {string} options.fallbackUrl - an url to route user to if goBack is not possible
*
* @returns {object} - action definition
*/
export function goBack(options: { fallbackUrl?: string }) {
const { fallbackUrl } = options || {};
if (history.canGoBack()) {
browserHistory.goBack();
} else if (fallbackUrl) {
browserHistory.push(fallbackUrl);
}
return {
type: 'noop',
};
}
export function redirect(url: string): () => Promise<void> {
loader.show();
return () =>
new Promise(() => {
// do not resolve promise to make loader visible and
// overcome app rendering
location.href = url;
});
}
const PASSWORD_REQUIRED = 'error.password_required';
const LOGIN_REQUIRED = 'error.login_required';
const ACTIVATION_REQUIRED = 'error.account_not_activated';
const TOTP_REQUIRED = 'error.totp_required';
export function login({
login = '',
password = '',
totp,
rememberMe = false,
}: {
login: string;
password?: string;
totp?: string;
rememberMe?: boolean;
}) {
return wrapInLoader(dispatch =>
loginEndpoint({ login, password, totp, rememberMe })
.then(authHandler(dispatch))
.catch(resp => {
if (resp.errors) {
if (resp.errors.password === PASSWORD_REQUIRED) {
return dispatch(setLogin(login));
} else if (resp.errors.login === ACTIVATION_REQUIRED) {
return dispatch(needActivation());
} else if (resp.errors.totp === TOTP_REQUIRED) {
return dispatch(
requestTotp({
login,
password,
rememberMe,
}),
);
} else if (resp.errors.login === LOGIN_REQUIRED && password) {
logger.warn('No login on password panel');
return dispatch(logoutAll());
}
}
return Promise.reject(resp);
})
.catch(validationErrorsHandler(dispatch)),
);
}
export function acceptRules() {
return wrapInLoader(dispatch =>
dispatch(userAcceptRules()).catch(validationErrorsHandler(dispatch)),
);
}
export function forgotPassword({
login = '',
captcha = '',
}: {
login: string;
captcha: string;
}) {
return wrapInLoader((dispatch, getState) =>
forgotPasswordEndpoint(login, captcha)
.then(({ data = {} }) =>
dispatch(
updateUser({
maskedEmail: data.emailMask || getState().user.email,
}),
),
)
.catch(validationErrorsHandler(dispatch)),
);
}
export function recoverPassword({
key = '',
newPassword = '',
newRePassword = '',
}: {
key: string;
newPassword: string;
newRePassword: string;
}) {
return wrapInLoader(dispatch =>
recoverPasswordEndpoint(key, newPassword, newRePassword)
.then(authHandler(dispatch))
.catch(validationErrorsHandler(dispatch, '/forgot-password')),
);
}
export function register({
email = '',
username = '',
password = '',
rePassword = '',
captcha = '',
rulesAgreement = false,
}: {
email: string;
username: string;
password: string;
rePassword: string;
captcha: string;
rulesAgreement: boolean;
}) {
return wrapInLoader((dispatch, getState) =>
signup
.register({
email,
username,
password,
rePassword,
rulesAgreement,
lang: getState().user.lang,
captcha,
})
.then(() => {
dispatch(
updateUser({
username,
email,
}),
);
dispatch(needActivation());
browserHistory.push('/activation');
})
.catch(validationErrorsHandler(dispatch)),
);
}
export function activate({
key = '',
}: {
key: string;
}): ThunkAction<Promise<Account>> {
return wrapInLoader(dispatch =>
signup
.activate({ key })
.then(authHandler(dispatch))
.catch(validationErrorsHandler(dispatch, '/resend-activation')),
);
}
export function resendActivation({
email = '',
captcha,
}: {
email: string;
captcha: string;
}) {
return wrapInLoader(dispatch =>
signup
.resendActivation({ email, captcha })
.then(resp => {
dispatch(
updateUser({
email,
}),
);
return resp;
})
.catch(validationErrorsHandler(dispatch)),
);
}
export function contactUs() {
return createPopup({ Popup: ContactForm });
}
export const SET_CREDENTIALS = 'auth:setCredentials';
/**
* Sets login in credentials state
*
* Resets the state, when `null` is passed
*
* @param {string|null} login
*
* @returns {object}
*/
export function setLogin(login: string | null) {
return {
type: SET_CREDENTIALS,
payload: login
? {
login,
}
: null,
};
}
export function relogin(login: string | null): ThunkAction {
return (dispatch, getState) => {
const credentials = getCredentials(getState());
const returnUrl =
credentials.returnUrl || location.pathname + location.search;
dispatch({
type: SET_CREDENTIALS,
payload: {
login,
returnUrl,
isRelogin: true,
},
});
browserHistory.push('/login');
};
}
function requestTotp({
login,
password,
rememberMe,
}: {
login: string;
password: string;
rememberMe: boolean;
}): ThunkAction {
return (dispatch, getState) => {
// merging with current credentials to propogate returnUrl
const credentials = getCredentials(getState());
dispatch({
type: SET_CREDENTIALS,
payload: {
...credentials,
login,
password,
rememberMe,
isTotpRequired: true,
},
});
};
}
export const SET_SWITCHER = 'auth:setAccountSwitcher';
export function setAccountSwitcher(isOn: boolean) {
return {
type: SET_SWITCHER,
payload: isOn,
};
}
export const ERROR = 'auth:error';
export function setErrors(errors: { [key: string]: ValidationError } | null) {
return {
type: ERROR,
payload: errors,
error: true,
};
}
export function clearErrors() {
return setErrors(null);
}
const KNOWN_SCOPES = [
'minecraft_server_session',
'offline_access',
'account_info',
'account_email',
];
/**
* @param {object} oauthData
* @param {string} oauthData.clientId
* @param {string} oauthData.redirectUrl
* @param {string} oauthData.responseType
* @param {string} oauthData.description
* @param {string} oauthData.scope
* @param {string} [oauthData.prompt='none'] - comma-separated list of values to adjust auth flow
* Posible values:
* * none - default behaviour
* * consent - forcibly prompt user for rules acceptance
* * select_account - force account choosage, even if user has only one
* @param {string} oauthData.loginHint - allows to choose the account, which will be used for auth
* The possible values: account id, email, username
* @param {string} oauthData.state
*
* @returns {Promise}
*/
export function oAuthValidate(oauthData: OauthData) {
// TODO: move to oAuth actions?
// test request: /oauth?client_id=ely&redirect_uri=http%3A%2F%2Fely.by&response_type=code&scope=minecraft_server_session&description=foo
return wrapInLoader(dispatch =>
oauth
.validate(oauthData)
.then(resp => {
const { scopes } = resp.session;
const invalidScopes = scopes.filter(
scope => !KNOWN_SCOPES.includes(scope),
);
let prompt = (oauthData.prompt || 'none')
.split(',')
.map(item => item.trim());
if (prompt.includes('none')) {
prompt = ['none'];
}
if (invalidScopes.length) {
logger.error('Got invalid scopes after oauth validation', {
invalidScopes,
});
}
dispatch(setClient(resp.client));
dispatch(
setOAuthRequest({
...resp.oAuth,
prompt: oauthData.prompt || 'none',
loginHint: oauthData.loginHint,
}),
);
dispatch(setScopes(scopes));
localStorage.setItem(
'oauthData',
JSON.stringify({
// @see services/authFlow/AuthFlow
timestamp: Date.now(),
payload: oauthData,
}),
);
})
.catch(handleOauthParamsValidation),
);
}
/**
* @param {object} params
* @param {bool} params.accept=false
*
* @returns {Promise}
*/
export function oAuthComplete(params: { accept?: boolean } = {}) {
return wrapInLoader(
async (
dispatch,
getState,
): Promise<{
success: boolean;
redirectUri: string;
}> => {
const oauthData = getState().auth.oauth;
if (!oauthData) {
throw new Error('Can not complete oAuth. Oauth data does not exist');
}
try {
const resp = await oauth.complete(oauthData, params);
localStorage.removeItem('oauthData');
if (resp.redirectUri.startsWith('static_page')) {
const displayCode = resp.redirectUri === 'static_page_with_code';
const [, code] = resp.redirectUri.match(/code=(.+)&/) || [];
[, resp.redirectUri] = resp.redirectUri.match(/^(.+)\?/) || [];
dispatch(
setOAuthCode({
success: resp.success,
code,
displayCode,
}),
);
}
return resp;
} catch (error) {
const resp:
| {
acceptRequired: boolean;
}
| {
unauthorized: boolean;
} = error;
if ('acceptRequired' in resp) {
dispatch(requirePermissionsAccept());
return Promise.reject(resp);
}
return handleOauthParamsValidation(resp);
}
},
);
}
function handleOauthParamsValidation(
resp: {
[key: string]: any;
userMessage?: string;
} = {},
) {
dispatchBsod();
localStorage.removeItem('oauthData');
// eslint-disable-next-line no-alert
resp.userMessage && setTimeout(() => alert(resp.userMessage), 500); // 500 ms to allow re-render
return Promise.reject(resp);
}
export const SET_CLIENT = 'set_client';
export function setClient({ id, name, description }: Client) {
return {
type: SET_CLIENT,
payload: { id, name, description },
};
}
export function resetOAuth(): ThunkAction {
return (dispatch): void => {
localStorage.removeItem('oauthData');
dispatch(setOAuthRequest({}));
};
}
/**
* Resets all temporary state related to auth
*/
export function resetAuth(): ThunkAction {
return (dispatch, getSate): Promise<void> => {
dispatch(setLogin(null));
dispatch(resetOAuth());
// ensure current account is valid
const activeAccount = getActiveAccount(getSate());
if (activeAccount) {
return Promise.resolve(dispatch(authenticate(activeAccount)))
.then(() => {})
.catch(() => {
// its okay. user will be redirected to an appropriate place
});
}
return Promise.resolve();
};
}
export const SET_OAUTH = 'set_oauth';
export function setOAuthRequest(oauth: {
client_id?: string;
redirect_uri?: string;
response_type?: string;
scope?: string;
prompt?: string;
loginHint?: string;
state?: string;
}) {
return {
type: SET_OAUTH,
payload: {
clientId: oauth.client_id,
redirectUrl: oauth.redirect_uri,
responseType: oauth.response_type,
scope: oauth.scope,
prompt: oauth.prompt,
loginHint: oauth.loginHint,
state: oauth.state,
},
};
}
export const SET_OAUTH_RESULT = 'set_oauth_result';
export function setOAuthCode(oauth: {
success: boolean;
code: string;
displayCode: boolean;
}) {
return {
type: SET_OAUTH_RESULT,
payload: {
success: oauth.success,
code: oauth.code,
displayCode: oauth.displayCode,
},
};
}
export const REQUIRE_PERMISSIONS_ACCEPT = 'require_permissions_accept';
export function requirePermissionsAccept() {
return {
type: REQUIRE_PERMISSIONS_ACCEPT,
};
}
export const SET_SCOPES = 'set_scopes';
export function setScopes(scopes: Scope[]) {
if (!(scopes instanceof Array)) {
throw new Error('Scopes must be array');
}
return {
type: SET_SCOPES,
payload: scopes,
};
}
export const SET_LOADING_STATE = 'set_loading_state';
export function setLoadingState(isLoading: boolean) {
return {
type: SET_LOADING_STATE,
payload: isLoading,
};
}
function wrapInLoader<T>(fn: ThunkAction<Promise<T>>): ThunkAction<Promise<T>> {
return (dispatch, getState) => {
dispatch(setLoadingState(true));
const endLoading = () => dispatch(setLoadingState(false));
return fn(dispatch, getState, undefined).then(
resp => {
endLoading();
return resp;
},
resp => {
endLoading();
return Promise.reject(resp);
},
);
};
}
function needActivation() {
return updateUser({
isActive: false,
isGuest: false,
});
}
function authHandler(dispatch: Dispatch) {
return (resp: OAuthResponse): Promise<Account> =>
dispatch(
authenticate({
token: resp.access_token,
refreshToken: resp.refresh_token || null,
}),
).then(resp => {
dispatch(setLogin(null));
return resp;
});
}
function validationErrorsHandler(dispatch: Dispatch, repeatUrl?: string) {
return resp => {
if (resp.errors) {
const firstError = Object.keys(resp.errors)[0];
const error = {
type: resp.errors[firstError],
payload: {
isGuest: true,
repeatUrl: '',
},
};
if (resp.data) {
// TODO: this should be formatted on backend
Object.assign(error.payload, resp.data);
}
if (
['error.key_not_exists', 'error.key_expire'].includes(error.type) &&
repeatUrl
) {
// TODO: this should be formatted on backend
error.payload.repeatUrl = repeatUrl;
}
resp.errors[firstError] = error;
dispatch(setErrors(resp.errors));
}
return Promise.reject(resp);
};
}

View File

@ -0,0 +1,8 @@
{
"accountActivationTitle": "Account activation",
"activationMailWasSent": "Please check {email} for the message with further instructions",
"activationMailWasSentNoEmail": "Please check your Email for the message with further instructions",
"confirmEmail": "Confirm Email",
"didNotReceivedEmail": "Did not received Email?",
"enterTheCode": "Enter the code from Email here"
}

View File

@ -0,0 +1,15 @@
import factory from '../factory';
import messages from './Activation.intl.json';
import Body from './ActivationBody';
export default factory({
title: messages.accountActivationTitle,
body: Body,
footer: {
color: 'blue',
label: messages.confirmEmail,
},
links: {
label: messages.didNotReceivedEmail,
},
});

View File

@ -0,0 +1,66 @@
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Input } from 'app/components/ui/form';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import styles from './activation.scss';
import messages from './Activation.intl.json';
export default class ActivationBody extends BaseAuthBody {
static displayName = 'ActivationBody';
static panelId = 'activation';
static propTypes = {
match: PropTypes.shape({
params: PropTypes.shape({
key: PropTypes.string,
}),
}),
};
autoFocusField =
this.props.match.params && this.props.match.params.key ? null : 'key';
render() {
const { key } = this.props.match.params;
const { email } = this.context.user;
return (
<div>
{this.renderErrors()}
<div className={styles.description}>
<div className={styles.descriptionImage} />
<div className={styles.descriptionText}>
{email ? (
<Message
{...messages.activationMailWasSent}
values={{
email: <b>{email}</b>,
}}
/>
) : (
<Message {...messages.activationMailWasSentNoEmail} />
)}
</div>
</div>
<div className={styles.formRow}>
<Input
{...this.bindField('key')}
color="blue"
center
required
value={key}
readOnly={!!key}
autoComplete="off"
placeholder={messages.enterTheCode}
/>
</div>
</div>
);
}
}

View File

@ -0,0 +1,19 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
.description {
}
.descriptionImage {
composes: envelope from '~app/components/ui/icons.scss';
font-size: 100px;
color: $blue;
}
.descriptionText {
font-family: $font-family-title;
margin: 5px 0 19px;
line-height: 1.4;
font-size: 16px;
}

View File

@ -0,0 +1,7 @@
{
"appName": "Ely Accounts",
"goToAuth": "Go to auth",
"appDescription": "You are on the Ely.by authorization service, that allows you to safely perform any operations on your account. This single entry point for websites and desktop software, including game launchers.",
"useItYourself": "Visit our {link}, to learn how to use this service in you projects.",
"documentation": "documentation"
}

View File

@ -0,0 +1,57 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Button } from 'app/components/ui/form';
import { FooterMenu } from 'app/components/footerMenu';
import styles from './appInfo.scss';
import messages from './AppInfo.intl.json';
export default class AppInfo extends React.Component<{
name?: string;
description?: string;
onGoToAuth: () => void;
}> {
render() {
const { name, description, onGoToAuth } = this.props;
return (
<div className={styles.appInfo}>
<div className={styles.logoContainer}>
<h2 className={styles.logo}>
{name ? name : <Message {...messages.appName} />}
</h2>
</div>
<div className={styles.descriptionContainer}>
{description ? (
<p className={styles.description}>{description}</p>
) : (
<div>
<p className={styles.description}>
<Message {...messages.appDescription} />
</p>
<p className={styles.description}>
<Message
{...messages.useItYourself}
values={{
link: (
<a href="http://docs.ely.by/oauth.html">
<Message {...messages.documentation} />
</a>
),
}}
/>
</p>
</div>
)}
</div>
<div className={styles.goToAuth}>
<Button onClick={onGoToAuth} label={messages.goToAuth} />
</div>
<div className={styles.footer}>
<FooterMenu />
</div>
</div>
);
}
}

View File

@ -0,0 +1,72 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
.appInfo {
max-width: 270px;
margin: 0 auto;
padding: 55px 25px;
}
.logoContainer {
position: relative;
padding: 15px 0;
&:after {
content: '';
display: block;
position: absolute;
left: 0;
bottom: 0;
height: 3px;
width: 40px;
background: $green;
}
}
.logo {
font-family: $font-family-title;
color: #fff;
font-size: 36px;
}
.descriptionContainer {
margin: 20px 0;
}
.description {
$font-color: #ccc;
font-family: $font-family-base;
color: $font-color;
font-size: 13px;
line-height: 1.7;
margin-top: 7px;
a {
color: lighten($font-color, 10%);
border-bottom-color: #666;
&:hover {
color: $font-color;
}
}
}
.goToAuth {
}
@media (min-width: 720px) {
.goToAuth {
display: none;
}
}
.footer {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
text-align: center;
line-height: 1.5;
}

View File

@ -0,0 +1,3 @@
.checkboxInput {
margin-top: 15px;
}

View File

@ -0,0 +1,45 @@
import PropTypes from 'prop-types';
import React from 'react';
import errorsDict from 'app/services/errorsDict';
import { PanelBodyHeader } from 'app/components/ui/Panel';
let autoHideTimer;
function resetTimer() {
if (autoHideTimer) {
clearTimeout(autoHideTimer);
autoHideTimer = null;
}
}
export default function AuthError({ error, onClose = function() {} }) {
resetTimer();
if (error.payload && error.payload.canRepeatIn) {
error.payload.msLeft = error.payload.canRepeatIn * 1000;
setTimeout(onClose, error.payload.msLeft - Date.now() + 1500); // 1500 to let the user see, that time is elapsed
}
return (
<PanelBodyHeader
type="error"
onClose={() => {
resetTimer();
onClose();
}}
>
{errorsDict.resolve(error)}
</PanelBodyHeader>
);
}
AuthError.displayName = 'AuthError';
AuthError.propTypes = {
error: PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
type: PropTypes.string,
payload: PropTypes.object,
}),
]).isRequired,
onClose: PropTypes.func,
};

View File

@ -0,0 +1,7 @@
{
"chooseAccountTitle": "Choose an account",
"addAccount": "Log into another account",
"logoutAll": "Log out from all accounts",
"pleaseChooseAccount": "Please select an account you're willing to use",
"pleaseChooseAccountForApp": "Please select an account that you want to use to authorize {appName}"
}

View File

@ -0,0 +1,16 @@
import factory from '../factory';
import messages from './ChooseAccount.intl.json';
import Body from './ChooseAccountBody';
export default factory({
title: messages.chooseAccountTitle,
body: Body,
footer: {
label: messages.addAccount,
},
links: [
{
label: messages.logoutAll,
},
],
});

View File

@ -0,0 +1,52 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import { AccountSwitcher } from 'app/components/accounts';
import styles from './chooseAccount.scss';
import messages from './ChooseAccount.intl.json';
export default class ChooseAccountBody extends BaseAuthBody {
static displayName = 'ChooseAccountBody';
static panelId = 'chooseAccount';
render() {
const { client } = this.context.auth;
return (
<div>
{this.renderErrors()}
<div className={styles.description}>
{client ? (
<Message
{...messages.pleaseChooseAccountForApp}
values={{
appName: <span className={styles.appName}>{client.name}</span>,
}}
/>
) : (
<div className={styles.description}>
<Message {...messages.pleaseChooseAccount} />
</div>
)}
</div>
<div className={styles.accountSwitcherContainer}>
<AccountSwitcher
allowAdd={false}
allowLogout={false}
highlightActiveAccount={false}
onSwitch={this.onSwitch}
/>
</div>
</div>
);
}
onSwitch = account => {
this.context.resolve(account);
};
}

View File

@ -0,0 +1,18 @@
@import '~app/components/ui/panel.scss';
@import '~app/components/ui/fonts.scss';
.accountSwitcherContainer {
margin-left: -$bodyLeftRightPadding;
margin-right: -$bodyLeftRightPadding;
}
.description {
font-family: $font-family-title;
margin: 5px 0 19px;
line-height: 1.4;
font-size: 16px;
}
.appName {
color: #fff;
}

View File

@ -0,0 +1,50 @@
import React from 'react';
import { Button } from 'app/components/ui/form';
import RejectionLink, {
RejectionLinkProps,
} from 'app/components/auth/RejectionLink';
import AuthTitle from 'app/components/auth/AuthTitle';
import { MessageDescriptor } from 'react-intl';
import { Color } from 'app/components/ui';
/**
* @param {object} options
* @param {string|object} options.title - panel title
* @param {React.ReactElement} options.body
* @param {object} options.footer - config for footer Button
* @param {Array|object|null} options.links - link config or an array of link configs
*
* @returns {object} - structure, required for auth panel to work
*/
export default function({
title,
body,
footer,
links,
}: {
title: MessageDescriptor;
body: React.ElementType;
footer: {
color?: Color;
label: string | MessageDescriptor;
autoFocus?: boolean;
};
links?: RejectionLinkProps | RejectionLinkProps[];
}) {
return () => ({
Title: () => <AuthTitle title={title} />,
Body: body,
Footer: () => <Button type="submit" {...footer} />,
Links: () =>
links ? (
<span>
{([] as RejectionLinkProps[])
.concat(links)
.map((link, index) => [
index ? ' | ' : '',
<RejectionLink {...link} key={index} />,
])}
</span>
) : null,
});
}

View File

@ -0,0 +1,7 @@
{
"authForAppSuccessful": "Authorization for {appName} was successfully completed",
"authForAppFailed": "Authorization for {appName} was failed",
"waitAppReaction": "Please, wait till your application response",
"passCodeToApp": "To complete authorization process, please, provide the following code to {appName}",
"copy": "Copy"
}

View File

@ -0,0 +1,110 @@
import React from 'react';
import { connect } from 'react-redux';
import { FormattedMessage as Message } from 'react-intl';
import Helmet from 'react-helmet';
import { Button } from 'app/components/ui/form';
import copy from 'app/services/copy';
import { RootState } from 'app/reducers';
import messages from './Finish.intl.json';
import styles from './finish.scss';
interface Props {
appName: string;
code?: string;
state: string;
displayCode?: string;
success?: boolean;
}
class Finish extends React.Component<Props> {
render() {
const { appName, code, state, displayCode, success } = this.props;
const authData = JSON.stringify({
// eslint-disable-next-line @typescript-eslint/camelcase
auth_code: code,
state,
});
history.pushState(null, document.title, `#${authData}`);
return (
<div className={styles.finishPage}>
<Helmet title={authData} />
{success ? (
<div>
<div className={styles.successBackground} />
<div className={styles.greenTitle}>
<Message
{...messages.authForAppSuccessful}
values={{
appName: <span className={styles.appName}>{appName}</span>,
}}
/>
</div>
{displayCode ? (
<div>
<div className={styles.description}>
<Message {...messages.passCodeToApp} values={{ appName }} />
</div>
<div className={styles.codeContainer}>
<div className={styles.code}>{code}</div>
</div>
<Button
color="green"
small
label={messages.copy}
onClick={this.onCopyClick}
/>
</div>
) : (
<div className={styles.description}>
<Message {...messages.waitAppReaction} />
</div>
)}
</div>
) : (
<div>
<div className={styles.failBackground} />
<div className={styles.redTitle}>
<Message
{...messages.authForAppFailed}
values={{
appName: <span className={styles.appName}>{appName}</span>,
}}
/>
</div>
<div className={styles.description}>
<Message {...messages.waitAppReaction} />
</div>
</div>
)}
</div>
);
}
onCopyClick = event => {
event.preventDefault();
const { code } = this.props;
if (code) {
copy(code);
}
};
}
export default connect(({ auth }: RootState) => {
if (!auth || !auth.client || !auth.oauth) {
throw new Error('Can not connect Finish component. No auth data in state');
}
return {
appName: auth.client.name,
code: auth.oauth.code,
displayCode: auth.oauth.displayCode,
state: auth.oauth.state,
success: auth.oauth.success,
};
})(Finish);

View File

@ -0,0 +1,76 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
.finishPage {
font-family: $font-family-title;
position: relative;
max-width: 515px;
padding-top: 40px;
margin: 0 auto;
text-align: center;
}
.iconBackground {
position: absolute;
top: -15px;
transform: translateX(-50%);
font-size: 200px;
color: #e0d9cf;
z-index: -1;
}
.successBackground {
composes: checkmark from '~app/components/ui/icons.scss';
@extend .iconBackground;
}
.failBackground {
composes: close from '~app/components/ui/icons.scss';
@extend .iconBackground;
}
.title {
font-size: 22px;
margin-bottom: 10px;
}
.greenTitle {
composes: title;
color: $green;
.appName {
color: darker($green);
}
}
.redTitle {
composes: title;
color: $red;
.appName {
color: darker($red);
}
}
.description {
font-size: 18px;
margin-bottom: 10px;
}
.codeContainer {
margin-bottom: 5px;
margin-top: 35px;
}
.code {
$border: 5px solid darker($green);
display: inline-block;
border-right: $border;
border-left: $border;
padding: 5px 10px;
word-break: break-all;
text-align: center;
}

View File

@ -0,0 +1,7 @@
{
"title": "Forgot password",
"sendMail": "Send mail",
"specifyEmail": "Specify the registration Email address or last used username for your account and we will send an Email with instructions for further password recovery.",
"pleasePressButton": "Please press the button bellow to get an Email with password recovery code.",
"alreadyHaveCode": "Already have a code"
}

View File

@ -0,0 +1,16 @@
import factory from '../factory';
import messages from './ForgotPassword.intl.json';
import Body from './ForgotPasswordBody';
export default factory({
title: messages.title,
body: Body,
footer: {
color: 'lightViolet',
autoFocus: true,
label: messages.sendMail,
},
links: {
label: messages.alreadyHaveCode,
},
});

View File

@ -0,0 +1,89 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Input, Captcha } from 'app/components/ui/form';
import { getLogin } from 'app/components/auth/reducer';
import { PanelIcon } from 'app/components/ui/Panel';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import styles from './forgotPassword.scss';
import messages from './ForgotPassword.intl.json';
export default class ForgotPasswordBody extends BaseAuthBody {
static displayName = 'ForgotPasswordBody';
static panelId = 'forgotPassword';
static hasGoBack = true;
state = {
isLoginEdit: !this.getLogin(),
};
autoFocusField = this.state.isLoginEdit ? 'login' : null;
render() {
const login = this.getLogin();
const isLoginEditShown = this.state.isLoginEdit;
return (
<div>
{this.renderErrors()}
<PanelIcon icon="lock" />
{isLoginEditShown ? (
<div>
<p className={styles.descriptionText}>
<Message {...messages.specifyEmail} />
</p>
<Input
{...this.bindField('login')}
icon="envelope"
color="lightViolet"
required
placeholder={messages.accountEmail}
defaultValue={login}
/>
</div>
) : (
<div>
<div className={styles.login}>
{login}
<span className={styles.editLogin} onClick={this.onClickEdit} />
</div>
<p className={styles.descriptionText}>
<Message {...messages.pleasePressButton} />
</p>
</div>
)}
<Captcha {...this.bindField('captcha')} delay={600} />
</div>
);
}
serialize() {
const data = super.serialize();
if (!data.login) {
data.login = this.getLogin();
}
return data;
}
getLogin() {
const login = getLogin(this.context);
const { user } = this.context;
return login || user.username || user.email || '';
}
onClickEdit = () => {
this.setState({
isLoginEdit: true,
});
this.context.requestRedraw().then(() => this.form.focus('login'));
};
}

View File

@ -0,0 +1,31 @@
@import '~app/components/ui/colors.scss';
.descriptionText {
font-size: 15px;
line-height: 1.4;
padding-bottom: 8px;
color: #aaa;
}
.login {
composes: email from '~app/components/auth/password/password.scss';
}
.editLogin {
composes: pencil from '~app/components/ui/icons.scss';
position: relative;
bottom: 1px;
padding-left: 3px;
color: #666666;
font-size: 10px;
transition: color 0.3s;
cursor: pointer;
&:hover {
color: #ccc;
}
}

View File

@ -0,0 +1,9 @@
.helpLinks {
margin: 8px 0;
position: relative;
height: 20px;
color: #444;
text-align: center;
font-size: 16px;
}

View File

@ -0,0 +1 @@
export { State as AuthState } from './reducer';

View File

@ -0,0 +1,6 @@
{
"createNewAccount": "Create new account",
"loginTitle": "Sign in",
"emailOrUsername": "Email or username",
"next": "Next"
}

View File

@ -0,0 +1,16 @@
import factory from '../factory';
import Body from './LoginBody';
import messages from './Login.intl.json';
export default factory({
title: messages.loginTitle,
body: Body,
footer: {
color: 'green',
label: messages.next,
},
links: {
isAvailable: context => !context.user.isGuest,
label: messages.createNewAccount,
},
});

View File

@ -0,0 +1,30 @@
import React from 'react';
import { Input } from 'app/components/ui/form';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import messages from './Login.intl.json';
export default class LoginBody extends BaseAuthBody {
static displayName = 'LoginBody';
static panelId = 'login';
static hasGoBack = state => {
return !state.user.isGuest;
};
autoFocusField = 'login';
render() {
return (
<div>
{this.renderErrors()}
<Input
{...this.bindField('login')}
icon="envelope"
required
placeholder={messages.emailOrUsername}
/>
</div>
);
}
}

View File

@ -0,0 +1,4 @@
{
"enterTotp": "Enter code",
"description": "In order to sign in this account, you need to enter a one-time password from mobile application"
}

View File

@ -0,0 +1,13 @@
import factory from '../factory';
import Body from './MfaBody';
import messages from './Mfa.intl.json';
import passwordMessages from '../password/Password.intl.json';
export default factory({
title: messages.enterTotp,
body: Body,
footer: {
color: 'green',
label: passwordMessages.signInButton,
},
});

View File

@ -0,0 +1,37 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { PanelIcon } from 'app/components/ui/Panel';
import { Input } from 'app/components/ui/form';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import styles from './mfa.scss';
import messages from './Mfa.intl.json';
export default class MfaBody extends BaseAuthBody {
static panelId = 'mfa';
static hasGoBack = true;
autoFocusField = 'totp';
render() {
return (
<div>
{this.renderErrors()}
<PanelIcon icon="lock" />
<p className={styles.descriptionText}>
<Message {...messages.description} />
</p>
<Input
{...this.bindField('totp')}
icon="key"
required
placeholder={messages.enterTotp}
autoComplete="off"
/>
</div>
);
}
}

View File

@ -0,0 +1,6 @@
.descriptionText {
font-size: 15px;
line-height: 1.4;
padding-bottom: 8px;
color: #aaa;
}

View File

@ -0,0 +1,7 @@
{
"passwordTitle": "Enter password",
"signInButton": "Sign in",
"forgotPassword": "Forgot password",
"accountPassword": "Account password",
"rememberMe": "Remember me on this device"
}

View File

@ -0,0 +1,15 @@
import factory from '../factory';
import Body from './PasswordBody';
import messages from './Password.intl.json';
export default factory({
title: messages.passwordTitle,
body: Body,
footer: {
color: 'green',
label: messages.signInButton,
},
links: {
label: messages.forgotPassword,
},
});

View File

@ -0,0 +1,53 @@
import React from 'react';
import icons from 'app/components/ui/icons.scss';
import { Input, Checkbox } from 'app/components/ui/form';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import authStyles from 'app/components/auth/auth.scss';
import styles from './password.scss';
import messages from './Password.intl.json';
export default class PasswordBody extends BaseAuthBody {
static displayName = 'PasswordBody';
static panelId = 'password';
static hasGoBack = true;
autoFocusField = 'password';
render() {
const { user } = this.context;
return (
<div>
{this.renderErrors()}
<div className={styles.miniProfile}>
<div className={styles.avatar}>
{user.avatar ? (
<img src={user.avatar} />
) : (
<span className={icons.user} />
)}
</div>
<div className={styles.email}>{user.email || user.username}</div>
</div>
<Input
{...this.bindField('password')}
icon="key"
type="password"
required
placeholder={messages.accountPassword}
/>
<div className={authStyles.checkboxInput}>
<Checkbox
{...this.bindField('rememberMe')}
defaultChecked
label={messages.rememberMe}
/>
</div>
</div>
);
}
}

View File

@ -0,0 +1,22 @@
@import '~app/components/ui/fonts.scss';
.avatar {
width: 90px;
height: 90px;
font-size: 90px;
line-height: 1;
margin: 0 auto;
img {
width: 100%;
}
}
.email {
font-family: $font-family-title;
font-size: 18px;
color: #fff;
margin-bottom: 15px;
margin-top: 10px;
}

View File

@ -0,0 +1,12 @@
{
"permissionsTitle": "Application permissions",
"youAuthorizedAs": "You authorized as:",
"theAppNeedsAccess1": "This application needs access",
"theAppNeedsAccess2": "to your data",
"decline": "Decline",
"approve": "Approve",
"scope_minecraft_server_session": "Authorization data for minecraft server",
"scope_offline_access": "Access to your profile data, when you offline",
"scope_account_info": "Access to your profile data (except Email)",
"scope_account_email": "Access to your Email address"
}

View File

@ -0,0 +1,16 @@
import factory from '../factory';
import messages from './Permissions.intl.json';
import Body from './PermissionsBody';
export default factory({
title: messages.permissionsTitle,
body: Body,
footer: {
color: 'orange',
autoFocus: true,
label: messages.approve,
},
links: {
label: messages.decline,
},
});

View File

@ -0,0 +1,65 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import icons from 'app/components/ui/icons.scss';
import { PanelBodyHeader } from 'app/components/ui/Panel';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import styles from './permissions.scss';
import messages from './Permissions.intl.json';
export default class PermissionsBody extends BaseAuthBody {
static displayName = 'PermissionsBody';
static panelId = 'permissions';
render() {
const { user } = this.context;
const { scopes } = this.context.auth;
return (
<div>
{this.renderErrors()}
<PanelBodyHeader>
<div className={styles.authInfo}>
<div className={styles.authInfoAvatar}>
{user.avatar ? (
<img src={user.avatar} />
) : (
<span className={icons.user} />
)}
</div>
<div className={styles.authInfoTitle}>
<Message {...messages.youAuthorizedAs} />
</div>
<div className={styles.authInfoEmail}>{user.username}</div>
</div>
</PanelBodyHeader>
<div className={styles.permissionsContainer}>
<div className={styles.permissionsTitle}>
<Message {...messages.theAppNeedsAccess1} />
<br />
<Message {...messages.theAppNeedsAccess2} />
</div>
<ul className={styles.permissionsList}>
{scopes.map(scope => {
const key = `scope_${scope}`;
const message = messages[key];
return (
<li key={key}>
{message ? (
<Message {...message} />
) : (
scope.replace(/^\w|_/g, match =>
match.replace('_', ' ').toUpperCase(),
)
)}
</li>
);
})}
</ul>
</div>
</div>
);
}
}

View File

@ -0,0 +1,77 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
.authInfo {
// Отступы сверху и снизу разные т.к. мы ужимаем высоту линии строки с логином на 2 пикселя и из-за этого теряем отступ снизу
padding: 5px 20px 7px;
text-align: left;
}
.authInfoAvatar {
$size: 30px;
float: left;
height: $size;
width: $size;
font-size: $size;
line-height: 1;
margin-right: 10px;
margin-top: 2px;
color: #aaa;
img {
width: 100%;
}
}
.authInfoTitle {
font-size: 14px;
color: #666;
}
.authInfoEmail {
font-family: $font-family-title;
font-size: 20px;
line-height: 16px;
color: #fff;
}
.permissionsContainer {
padding: 15px 12px;
text-align: left;
}
.permissionsTitle {
font-family: $font-family-title;
font-size: 18px;
color: #dd8650;
padding-bottom: 6px;
}
.permissionsList {
list-style: none;
margin-top: 10px;
li {
color: #a9a9a9;
font-size: 14px;
line-height: 1.4;
padding-bottom: 4px;
padding-left: 17px;
position: relative;
&:last-of-type {
padding-bottom: 0;
}
&:before {
content: '';
color: lighter($light_violet);
font-size: 39px; // ~ 9px
line-height: 9px;
position: absolute;
top: 6px;
left: -4px;
}
}
}

View File

@ -0,0 +1,12 @@
{
"title": "Restore password",
"contactSupport": "Contact support",
"messageWasSent": "The recovery code was sent to your account Email.",
"messageWasSentTo": "The recovery code was sent to your Email {email}.",
"enterCodeBelow": "Please enter the code received into the field below:",
"enterNewPasswordBelow": "Enter and repeat new password below:",
"change": "Change password",
"newPassword": "Enter new password",
"newRePassword": "Repeat new password",
"enterTheCode": "Enter confirmation code"
}

View File

@ -0,0 +1,15 @@
import factory from '../factory';
import messages from './RecoverPassword.intl.json';
import Body from './RecoverPasswordBody';
export default factory({
title: messages.title,
body: Body,
footer: {
color: 'lightViolet',
label: messages.change,
},
links: {
label: messages.contactSupport,
},
});

View File

@ -0,0 +1,89 @@
import PropTypes from 'prop-types';
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Input } from 'app/components/ui/form';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import styles from './recoverPassword.scss';
import messages from './RecoverPassword.intl.json';
// TODO: activation code field may be decoupled into common component and reused here and in activation panel
export default class RecoverPasswordBody extends BaseAuthBody {
static displayName = 'RecoverPasswordBody';
static panelId = 'recoverPassword';
static hasGoBack = true;
static propTypes = {
match: PropTypes.shape({
params: PropTypes.shape({
key: PropTypes.string,
}),
}),
};
autoFocusField =
this.props.match.params && this.props.match.params.key
? 'newPassword'
: 'key';
render() {
const { user } = this.context;
const { key } = this.props.match.params;
return (
<div>
{this.renderErrors()}
<p className={styles.descriptionText}>
{user.maskedEmail ? (
<Message
{...messages.messageWasSentTo}
values={{
email: <b>{user.maskedEmail}</b>,
}}
/>
) : (
<Message {...messages.messageWasSent} />
)}{' '}
<Message {...messages.enterCodeBelow} />
</p>
<Input
{...this.bindField('key')}
color="lightViolet"
center
required
value={key}
readOnly={!!key}
autoComplete="off"
placeholder={messages.enterTheCode}
/>
<p className={styles.descriptionText}>
<Message {...messages.enterNewPasswordBelow} />
</p>
<Input
{...this.bindField('newPassword')}
icon="key"
color="lightViolet"
type="password"
required
placeholder={messages.newPassword}
/>
<Input
{...this.bindField('newRePassword')}
icon="key"
color="lightViolet"
type="password"
required
placeholder={messages.newRePassword}
/>
</div>
);
}
}

View File

@ -0,0 +1,8 @@
@import '~app/components/ui/colors.scss';
.descriptionText {
font-size: 15px;
line-height: 1.4;
margin-bottom: 8px;
color: #aaa;
}

View File

@ -0,0 +1,47 @@
import expect from 'app/test/unexpected';
import auth from './reducer';
import {
setLogin,
SET_CREDENTIALS,
setAccountSwitcher,
SET_SWITCHER,
} from './actions';
describe('components/auth/reducer', () => {
describe(SET_CREDENTIALS, () => {
it('should set login', () => {
const expectedLogin = 'foo';
expect(
auth(undefined, setLogin(expectedLogin)).credentials,
'to satisfy',
{
login: expectedLogin,
},
);
});
});
describe(SET_SWITCHER, () => {
it('should be enabled by default', () =>
expect(auth(undefined, {} as any), 'to satisfy', {
isSwitcherEnabled: true,
}));
it('should enable switcher', () => {
const expectedValue = true;
expect(auth(undefined, setAccountSwitcher(expectedValue)), 'to satisfy', {
isSwitcherEnabled: expectedValue,
});
});
it('should disable switcher', () => {
const expectedValue = false;
expect(auth(undefined, setAccountSwitcher(expectedValue)), 'to satisfy', {
isSwitcherEnabled: expectedValue,
});
});
});
});

View File

@ -0,0 +1,192 @@
import { combineReducers } from 'redux';
import {
ERROR,
SET_CLIENT,
SET_OAUTH,
SET_OAUTH_RESULT,
SET_SCOPES,
SET_LOADING_STATE,
REQUIRE_PERMISSIONS_ACCEPT,
SET_CREDENTIALS,
SET_SWITCHER,
} from './actions';
type Credentials = {
login?: string;
password?: string;
rememberMe?: boolean;
returnUrl?: string;
isRelogin?: boolean;
isTotpRequired?: boolean;
};
export interface Client {
id: string;
name: string;
description: string;
}
export interface State {
credentials: Credentials;
error:
| string
| {
type: string;
payload: { [key: string]: any };
};
isLoading: boolean;
isSwitcherEnabled: boolean;
client: Client | null;
login: string;
oauth: {
clientId: string;
redirectUrl: string;
responseType: string;
description: string;
scope: string;
prompt: string;
loginHint: string;
state: string;
success?: boolean;
code?: string;
displayCode?: string;
acceptRequired?: boolean;
} | null;
scopes: string[];
}
export default combineReducers({
credentials,
error,
isLoading,
isSwitcherEnabled,
client,
oauth,
scopes,
});
function error(state = null, { type, payload = null, error = false }) {
switch (type) {
case ERROR:
if (!error) {
throw new Error('Expected payload with error');
}
return payload;
default:
return state;
}
}
function credentials(
state = {},
{
type,
payload,
}: {
type: string;
payload: Credentials | null;
},
) {
if (type === SET_CREDENTIALS) {
if (payload && typeof payload === 'object') {
return {
...payload,
};
}
return {};
}
return state;
}
function isSwitcherEnabled(state = true, { type, payload = false }) {
switch (type) {
case SET_SWITCHER:
if (typeof payload !== 'boolean') {
throw new Error('Expected payload of boolean type');
}
return payload;
default:
return state;
}
}
function isLoading(state = false, { type, payload = null }) {
switch (type) {
case SET_LOADING_STATE:
return !!payload;
default:
return state;
}
}
function client(state = null, { type, payload }) {
switch (type) {
case SET_CLIENT:
return {
id: payload.id,
name: payload.name,
description: payload.description,
};
default:
return state;
}
}
function oauth(state: State | null = null, { type, payload }) {
switch (type) {
case SET_OAUTH:
return {
clientId: payload.clientId,
redirectUrl: payload.redirectUrl,
responseType: payload.responseType,
scope: payload.scope,
prompt: payload.prompt,
loginHint: payload.loginHint,
state: payload.state,
};
case SET_OAUTH_RESULT:
return {
...state,
success: payload.success,
code: payload.code,
displayCode: payload.displayCode,
};
case REQUIRE_PERMISSIONS_ACCEPT:
return {
...state,
acceptRequired: true,
};
default:
return state;
}
}
function scopes(state = [], { type, payload = [] }) {
switch (type) {
case SET_SCOPES:
return payload;
default:
return state;
}
}
export function getLogin(state: { [key: string]: any }): string | null {
return state.auth.credentials.login || null;
}
export function getCredentials(state: { [key: string]: any }): Credentials {
return state.auth.credentials;
}

View File

@ -0,0 +1,10 @@
{
"registerTitle": "Sign Up",
"yourNickname": "Your nickname",
"yourEmail": "Your Email",
"accountPassword": "Account password",
"repeatPassword": "Repeat password",
"signUpButton": "Register",
"acceptRules": "I agree with {link}",
"termsOfService": "terms of service"
}

View File

@ -0,0 +1,23 @@
import factory from '../factory';
import activationMessages from '../activation/Activation.intl.json';
import forgotPasswordMessages from '../forgotPassword/ForgotPassword.intl.json';
import messages from './Register.intl.json';
import Body from './RegisterBody';
export default factory({
title: messages.registerTitle,
body: Body,
footer: {
color: 'blue',
label: messages.signUpButton,
},
links: [
{
label: activationMessages.didNotReceivedEmail,
payload: { requestEmail: true },
},
{
label: forgotPasswordMessages.alreadyHaveCode,
},
],
});

View File

@ -0,0 +1,84 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Link } from 'react-router-dom';
import { Input, Checkbox, Captcha } from 'app/components/ui/form';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import passwordMessages from '../password/Password.intl.json';
import styles from '../auth.scss';
import messages from './Register.intl.json';
// TODO: password and username can be validate for length and sameness
export default class RegisterBody extends BaseAuthBody {
static displayName = 'RegisterBody';
static panelId = 'register';
autoFocusField = 'username';
render() {
return (
<div>
{this.renderErrors()}
<Input
{...this.bindField('username')}
icon="user"
color="blue"
type="text"
required
placeholder={messages.yourNickname}
/>
<Input
{...this.bindField('email')}
icon="envelope"
color="blue"
type="email"
required
placeholder={messages.yourEmail}
/>
<Input
{...this.bindField('password')}
icon="key"
color="blue"
type="password"
required
placeholder={passwordMessages.accountPassword}
/>
<Input
{...this.bindField('rePassword')}
icon="key"
color="blue"
type="password"
required
placeholder={messages.repeatPassword}
/>
<Captcha {...this.bindField('captcha')} delay={600} />
<div className={styles.checkboxInput}>
<Checkbox
{...this.bindField('rulesAgreement')}
color="blue"
required
label={
<Message
{...messages.acceptRules}
values={{
link: (
<Link to="/rules" target="_blank">
<Message {...messages.termsOfService} />
</Link>
),
}}
/>
}
/>
</div>
</div>
);
}
}

View File

@ -0,0 +1,5 @@
{
"title": "Did not received an Email",
"specifyYourEmail": "Please, enter an Email you've registered with and we will send you new activation code",
"sendNewEmail": "Send new Email"
}

View File

@ -0,0 +1,16 @@
import factory from '../factory';
import forgotPasswordMessages from '../forgotPassword/ForgotPassword.intl.json';
import messages from './ResendActivation.intl.json';
import Body from './ResendActivationBody';
export default factory({
title: messages.title,
body: Body,
footer: {
color: 'blue',
label: messages.sendNewEmail,
},
links: {
label: forgotPasswordMessages.alreadyHaveCode,
},
});

View File

@ -0,0 +1,40 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Input, Captcha } from 'app/components/ui/form';
import BaseAuthBody from '../BaseAuthBody';
import registerMessages from '../register/Register.intl.json';
import styles from './resendActivation.scss';
import messages from './ResendActivation.intl.json';
export default class ResendActivation extends BaseAuthBody {
static displayName = 'ResendActivation';
static panelId = 'resendActivation';
static hasGoBack = true;
autoFocusField = 'email';
render() {
return (
<div>
{this.renderErrors()}
<div className={styles.description}>
<Message {...messages.specifyYourEmail} />
</div>
<Input
{...this.bindField('email')}
icon="envelope"
color="blue"
type="email"
required
placeholder={registerMessages.yourEmail}
defaultValue={this.context.user.email}
/>
<Captcha {...this.bindField('captcha')} delay={600} />
</div>
);
}
}

View File

@ -0,0 +1,8 @@
@import '~app/components/ui/fonts.scss';
.description {
font-family: $font-family-title;
margin: 5px 0 19px;
line-height: 1.4;
font-size: 16px;
}