mirror of
https://github.com/elyby/accounts-frontend.git
synced 2025-05-31 14:11:58 +05:30
Create app namespace for all absolute requires of app modules. Move all packages under packages yarn workspace
This commit is contained in:
16
packages/app/components/auth/AuthTitle.tsx
Normal file
16
packages/app/components/auth/AuthTitle.tsx
Normal 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>
|
||||
);
|
||||
}
|
67
packages/app/components/auth/BaseAuthBody.tsx
Normal file
67
packages/app/components/auth/BaseAuthBody.tsx
Normal 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);
|
||||
}
|
||||
}
|
640
packages/app/components/auth/PanelTransition.tsx
Normal file
640
packages/app/components/auth/PanelTransition.tsx
Normal 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);
|
17
packages/app/components/auth/README.md
Normal file
17
packages/app/components/auth/README.md
Normal 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
|
46
packages/app/components/auth/RejectionLink.tsx
Normal file
46
packages/app/components/auth/RejectionLink.tsx
Normal 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;
|
@ -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."
|
||||
}
|
16
packages/app/components/auth/acceptRules/AcceptRules.ts
Normal file
16
packages/app/components/auth/acceptRules/AcceptRules.ts
Normal 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,
|
||||
},
|
||||
});
|
48
packages/app/components/auth/acceptRules/AcceptRulesBody.js
Normal file
48
packages/app/components/auth/acceptRules/AcceptRulesBody.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
16
packages/app/components/auth/acceptRules/acceptRules.scss
Normal file
16
packages/app/components/auth/acceptRules/acceptRules.scss
Normal 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;
|
||||
}
|
194
packages/app/components/auth/actions.test.ts
Normal file
194
packages/app/components/auth/actions.test.ts
Normal 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')]]);
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
658
packages/app/components/auth/actions.ts
Normal file
658
packages/app/components/auth/actions.ts
Normal 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);
|
||||
};
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
{
|
||||
"accountActivationTitle": "Account activation",
|
||||
"activationMailWasSent": "Please check {email} for the message with further instructions",
|
||||
"activationMailWasSentNoEmail": "Please check your E‑mail for the message with further instructions",
|
||||
"confirmEmail": "Confirm E‑mail",
|
||||
"didNotReceivedEmail": "Did not received E‑mail?",
|
||||
"enterTheCode": "Enter the code from E‑mail here"
|
||||
}
|
15
packages/app/components/auth/activation/Activation.ts
Normal file
15
packages/app/components/auth/activation/Activation.ts
Normal 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,
|
||||
},
|
||||
});
|
66
packages/app/components/auth/activation/ActivationBody.js
Normal file
66
packages/app/components/auth/activation/ActivationBody.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
19
packages/app/components/auth/activation/activation.scss
Normal file
19
packages/app/components/auth/activation/activation.scss
Normal 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;
|
||||
}
|
7
packages/app/components/auth/appInfo/AppInfo.intl.json
Normal file
7
packages/app/components/auth/appInfo/AppInfo.intl.json
Normal 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"
|
||||
}
|
57
packages/app/components/auth/appInfo/AppInfo.tsx
Normal file
57
packages/app/components/auth/appInfo/AppInfo.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
72
packages/app/components/auth/appInfo/appInfo.scss
Normal file
72
packages/app/components/auth/appInfo/appInfo.scss
Normal 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;
|
||||
}
|
3
packages/app/components/auth/auth.scss
Normal file
3
packages/app/components/auth/auth.scss
Normal file
@ -0,0 +1,3 @@
|
||||
.checkboxInput {
|
||||
margin-top: 15px;
|
||||
}
|
45
packages/app/components/auth/authError/AuthError.js
Normal file
45
packages/app/components/auth/authError/AuthError.js
Normal 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,
|
||||
};
|
@ -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}"
|
||||
}
|
16
packages/app/components/auth/chooseAccount/ChooseAccount.ts
Normal file
16
packages/app/components/auth/chooseAccount/ChooseAccount.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
});
|
@ -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);
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
50
packages/app/components/auth/factory.tsx
Normal file
50
packages/app/components/auth/factory.tsx
Normal 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,
|
||||
});
|
||||
}
|
7
packages/app/components/auth/finish/Finish.intl.json
Normal file
7
packages/app/components/auth/finish/Finish.intl.json
Normal 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"
|
||||
}
|
110
packages/app/components/auth/finish/Finish.tsx
Normal file
110
packages/app/components/auth/finish/Finish.tsx
Normal 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);
|
76
packages/app/components/auth/finish/finish.scss
Normal file
76
packages/app/components/auth/finish/finish.scss
Normal 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;
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": "Forgot password",
|
||||
"sendMail": "Send mail",
|
||||
"specifyEmail": "Specify the registration E‑mail address or last used username for your account and we will send an E‑mail with instructions for further password recovery.",
|
||||
"pleasePressButton": "Please press the button bellow to get an E‑mail with password recovery code.",
|
||||
"alreadyHaveCode": "Already have a code"
|
||||
}
|
@ -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,
|
||||
},
|
||||
});
|
@ -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'));
|
||||
};
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
9
packages/app/components/auth/helpLinks.scss
Normal file
9
packages/app/components/auth/helpLinks.scss
Normal file
@ -0,0 +1,9 @@
|
||||
.helpLinks {
|
||||
margin: 8px 0;
|
||||
position: relative;
|
||||
height: 20px;
|
||||
|
||||
color: #444;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
}
|
1
packages/app/components/auth/index.ts
Normal file
1
packages/app/components/auth/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { State as AuthState } from './reducer';
|
6
packages/app/components/auth/login/Login.intl.json
Normal file
6
packages/app/components/auth/login/Login.intl.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"createNewAccount": "Create new account",
|
||||
"loginTitle": "Sign in",
|
||||
"emailOrUsername": "E‑mail or username",
|
||||
"next": "Next"
|
||||
}
|
16
packages/app/components/auth/login/Login.ts
Normal file
16
packages/app/components/auth/login/Login.ts
Normal 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,
|
||||
},
|
||||
});
|
30
packages/app/components/auth/login/LoginBody.js
Normal file
30
packages/app/components/auth/login/LoginBody.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
4
packages/app/components/auth/mfa/Mfa.intl.json
Normal file
4
packages/app/components/auth/mfa/Mfa.intl.json
Normal 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"
|
||||
}
|
13
packages/app/components/auth/mfa/Mfa.tsx
Normal file
13
packages/app/components/auth/mfa/Mfa.tsx
Normal 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,
|
||||
},
|
||||
});
|
37
packages/app/components/auth/mfa/MfaBody.tsx
Normal file
37
packages/app/components/auth/mfa/MfaBody.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
6
packages/app/components/auth/mfa/mfa.scss
Normal file
6
packages/app/components/auth/mfa/mfa.scss
Normal file
@ -0,0 +1,6 @@
|
||||
.descriptionText {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
padding-bottom: 8px;
|
||||
color: #aaa;
|
||||
}
|
7
packages/app/components/auth/password/Password.intl.json
Normal file
7
packages/app/components/auth/password/Password.intl.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"passwordTitle": "Enter password",
|
||||
"signInButton": "Sign in",
|
||||
"forgotPassword": "Forgot password",
|
||||
"accountPassword": "Account password",
|
||||
"rememberMe": "Remember me on this device"
|
||||
}
|
15
packages/app/components/auth/password/Password.ts
Normal file
15
packages/app/components/auth/password/Password.ts
Normal 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,
|
||||
},
|
||||
});
|
53
packages/app/components/auth/password/PasswordBody.js
Normal file
53
packages/app/components/auth/password/PasswordBody.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
22
packages/app/components/auth/password/password.scss
Normal file
22
packages/app/components/auth/password/password.scss
Normal 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;
|
||||
}
|
@ -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 E‑mail)",
|
||||
"scope_account_email": "Access to your E‑mail address"
|
||||
}
|
16
packages/app/components/auth/permissions/Permissions.ts
Normal file
16
packages/app/components/auth/permissions/Permissions.ts
Normal 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,
|
||||
},
|
||||
});
|
65
packages/app/components/auth/permissions/PermissionsBody.js
Normal file
65
packages/app/components/auth/permissions/PermissionsBody.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
77
packages/app/components/auth/permissions/permissions.scss
Normal file
77
packages/app/components/auth/permissions/permissions.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,12 @@
|
||||
{
|
||||
"title": "Restore password",
|
||||
"contactSupport": "Contact support",
|
||||
"messageWasSent": "The recovery code was sent to your account E‑mail.",
|
||||
"messageWasSentTo": "The recovery code was sent to your E‑mail {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"
|
||||
}
|
@ -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,
|
||||
},
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,8 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
|
||||
.descriptionText {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
color: #aaa;
|
||||
}
|
47
packages/app/components/auth/reducer.test.ts
Normal file
47
packages/app/components/auth/reducer.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
192
packages/app/components/auth/reducer.ts
Normal file
192
packages/app/components/auth/reducer.ts
Normal 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;
|
||||
}
|
10
packages/app/components/auth/register/Register.intl.json
Normal file
10
packages/app/components/auth/register/Register.intl.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"registerTitle": "Sign Up",
|
||||
"yourNickname": "Your nickname",
|
||||
"yourEmail": "Your E‑mail",
|
||||
"accountPassword": "Account password",
|
||||
"repeatPassword": "Repeat password",
|
||||
"signUpButton": "Register",
|
||||
"acceptRules": "I agree with {link}",
|
||||
"termsOfService": "terms of service"
|
||||
}
|
23
packages/app/components/auth/register/Register.ts
Normal file
23
packages/app/components/auth/register/Register.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
});
|
84
packages/app/components/auth/register/RegisterBody.js
Normal file
84
packages/app/components/auth/register/RegisterBody.js
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
{
|
||||
"title": "Did not received an E‑mail",
|
||||
"specifyYourEmail": "Please, enter an E‑mail you've registered with and we will send you new activation code",
|
||||
"sendNewEmail": "Send new E‑mail"
|
||||
}
|
@ -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,
|
||||
},
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
Reference in New Issue
Block a user