mirror of
https://github.com/elyby/accounts-frontend.git
synced 2024-11-23 00:22:57 +05:30
Implemented strict mode for the project (broken tests, hundreds of @ts-ignore and new errors are included) [skip ci]
This commit is contained in:
parent
10e8b77acf
commit
96049ad4ad
2
@types/formatjs.d.ts
vendored
Normal file
2
@types/formatjs.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
declare module '@formatjs/intl-pluralrules/polyfill' {}
|
||||
declare module '@formatjs/intl-relativetimeformat/polyfill' {}
|
25
@types/redux-localstorage.d.ts
vendored
Normal file
25
@types/redux-localstorage.d.ts
vendored
Normal file
@ -0,0 +1,25 @@
|
||||
// https://github.com/elgerlambert/redux-localstorage/issues/78#issuecomment-323609784
|
||||
|
||||
// import * as Redux from 'redux';
|
||||
|
||||
declare module 'redux-localstorage' {
|
||||
export interface ConfigRS {
|
||||
key: string;
|
||||
merge?: any;
|
||||
slicer?: any;
|
||||
serialize?: (
|
||||
value: any,
|
||||
replacer?: (key: string, value: any) => any,
|
||||
space?: string | number,
|
||||
) => string;
|
||||
deserialize?: (
|
||||
text: string,
|
||||
reviver?: (key: any, value: any) => any,
|
||||
) => any;
|
||||
}
|
||||
|
||||
export default function persistState(
|
||||
paths: string | string[],
|
||||
config: ConfigRS,
|
||||
): () => any;
|
||||
}
|
86
@types/unexpected.d.ts
vendored
Normal file
86
@types/unexpected.d.ts
vendored
Normal file
@ -0,0 +1,86 @@
|
||||
declare module 'unexpected' {
|
||||
namespace unexpected {
|
||||
interface EnchantedPromise<T> extends Promise<T> {
|
||||
and<A extends Array<unknown> = []>(
|
||||
assertionName: string,
|
||||
subject: unknown,
|
||||
...args: A
|
||||
): EnchantedPromise<any>;
|
||||
}
|
||||
|
||||
interface Expect {
|
||||
/**
|
||||
* @see http://unexpected.js.org/api/expect/
|
||||
*/
|
||||
<A extends Array<unknown> = []>(
|
||||
subject: unknown,
|
||||
assertionName: string,
|
||||
...args: A
|
||||
): EnchantedPromise<any>;
|
||||
|
||||
it<A extends Array<unknown> = []>(
|
||||
assertionName: string,
|
||||
subject?: unknown,
|
||||
...args: A
|
||||
): EnchantedPromise<any>;
|
||||
|
||||
/**
|
||||
* @see http://unexpected.js.org/api/clone/
|
||||
*/
|
||||
clone(): this;
|
||||
|
||||
/**
|
||||
* @see http://unexpected.js.org/api/addAssertion/
|
||||
*/
|
||||
addAssertion<T, A extends Array<unknown> = []>(
|
||||
pattern: string,
|
||||
handler: (expect: Expect, subject: T, ...args: A) => void,
|
||||
): this;
|
||||
|
||||
/**
|
||||
* @see http://unexpected.js.org/api/addType/
|
||||
*/
|
||||
addType<T>(typeDefinition: unexpected.TypeDefinition<T>): this;
|
||||
|
||||
/**
|
||||
* @see http://unexpected.js.org/api/fail/
|
||||
*/
|
||||
fail<A extends Array<unknown> = []>(format: string, ...args: A): void;
|
||||
fail<E extends Error>(error: E): void;
|
||||
|
||||
/**
|
||||
* @see http://unexpected.js.org/api/freeze/
|
||||
*/
|
||||
freeze(): this;
|
||||
|
||||
/**
|
||||
* @see http://unexpected.js.org/api/use/
|
||||
*/
|
||||
use(plugin: unexpected.PluginDefinition): this;
|
||||
}
|
||||
|
||||
interface PluginDefinition {
|
||||
name?: string;
|
||||
version?: string;
|
||||
dependencies?: Array<string>;
|
||||
installInto(expect: Expect): void;
|
||||
}
|
||||
|
||||
interface TypeDefinition<T> {
|
||||
name: string;
|
||||
identify(value: unknown): value is T;
|
||||
base?: string;
|
||||
equal?(a: T, b: T, equal: (a: unknown, b: unknown) => boolean): boolean;
|
||||
inspect?(
|
||||
value: T,
|
||||
depth: number,
|
||||
output: any,
|
||||
inspect: (value: unknown, depth: number) => any,
|
||||
): void;
|
||||
}
|
||||
}
|
||||
|
||||
const unexpected: unexpected.Expect;
|
||||
|
||||
export = unexpected;
|
||||
}
|
4
@types/webpack-loaders.d.ts
vendored
4
@types/webpack-loaders.d.ts
vendored
@ -26,9 +26,7 @@ declare module '*.jpg' {
|
||||
declare module '*.intl.json' {
|
||||
import { MessageDescriptor } from 'react-intl';
|
||||
|
||||
const descriptor: {
|
||||
[key: string]: MessageDescriptor;
|
||||
};
|
||||
const descriptor: Record<string, MessageDescriptor>;
|
||||
|
||||
export = descriptor;
|
||||
}
|
||||
|
@ -16,7 +16,8 @@
|
||||
"license": "Apache-2.0",
|
||||
"repository": "https://github.com/elyby/accounts-frontend",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
"node": ">=10.0.0",
|
||||
"yarn": "1.19.1"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
@ -124,6 +125,7 @@
|
||||
"@storybook/addons": "^5.3.4",
|
||||
"@storybook/react": "^5.3.4",
|
||||
"@types/jest": "^24.9.0",
|
||||
"@types/sinon": "^7.5.1",
|
||||
"@typescript-eslint/eslint-plugin": "^2.16.0",
|
||||
"@typescript-eslint/parser": "^2.16.0",
|
||||
"babel-loader": "^8.0.0",
|
||||
|
@ -3,7 +3,7 @@ import { connect } from 'react-redux';
|
||||
import clsx from 'clsx';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import loader from 'app/services/loader';
|
||||
import * as loader from 'app/services/loader';
|
||||
import { SKIN_DARK, COLOR_WHITE, Skin } from 'app/components/ui';
|
||||
import { Button } from 'app/components/ui/form';
|
||||
import { authenticate, revoke } from 'app/components/accounts/actions';
|
||||
|
@ -12,13 +12,10 @@ import {
|
||||
} from 'app/components/accounts/actions';
|
||||
import {
|
||||
add,
|
||||
ADD,
|
||||
activate,
|
||||
ACTIVATE,
|
||||
remove,
|
||||
reset,
|
||||
} from 'app/components/accounts/actions/pure-actions';
|
||||
import { SET_LOCALE } from 'app/components/i18n/actions';
|
||||
import { updateUser, setUser } from 'app/components/user/actions';
|
||||
import { setLogin, setAccountSwitcher } from 'app/components/auth/actions';
|
||||
import { Dispatch, RootState } from 'app/reducers';
|
||||
@ -124,20 +121,20 @@ describe('components/accounts/actions', () => {
|
||||
]),
|
||||
));
|
||||
|
||||
it(`dispatches ${ADD} action`, () =>
|
||||
it(`dispatches accounts:add action`, () =>
|
||||
authenticate(account)(dispatch, getState, undefined).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [add(account)]),
|
||||
));
|
||||
|
||||
it(`dispatches ${ACTIVATE} action`, () =>
|
||||
it(`dispatches accounts:activate action`, () =>
|
||||
authenticate(account)(dispatch, getState, undefined).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [activate(account)]),
|
||||
));
|
||||
|
||||
it(`dispatches ${SET_LOCALE} action`, () =>
|
||||
it(`dispatches i18n:setLocale action`, () =>
|
||||
authenticate(account)(dispatch, getState, undefined).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
{ type: SET_LOCALE, payload: { locale: 'be' } },
|
||||
{ type: 'i18n:setLocale', payload: { locale: 'be' } },
|
||||
]),
|
||||
));
|
||||
|
||||
@ -479,7 +476,7 @@ describe('components/accounts/actions', () => {
|
||||
const key = `stranger${foreignAccount.id}`;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.setItem(key, 1);
|
||||
sessionStorage.setItem(key, '1');
|
||||
|
||||
logoutStrangers()(dispatch, getState, undefined);
|
||||
});
|
||||
|
@ -90,7 +90,7 @@ export function authenticate(
|
||||
|
||||
if (!newRefreshToken) {
|
||||
// mark user as stranger (user does not want us to remember his account)
|
||||
sessionStorage.setItem(`stranger${newAccount.id}`, 1);
|
||||
sessionStorage.setItem(`stranger${newAccount.id}`, '1');
|
||||
}
|
||||
|
||||
if (auth && auth.oauth && auth.oauth.clientId) {
|
||||
|
@ -1,78 +1,67 @@
|
||||
import {
|
||||
Account,
|
||||
AddAction,
|
||||
RemoveAction,
|
||||
ActivateAction,
|
||||
UpdateTokenAction,
|
||||
ResetAction,
|
||||
} from '../reducer';
|
||||
import { Action as ReduxAction } from 'redux';
|
||||
import { Account } from 'app/components/accounts/reducer';
|
||||
|
||||
interface AddAction extends ReduxAction {
|
||||
type: 'accounts:add';
|
||||
payload: Account;
|
||||
}
|
||||
|
||||
export const ADD = 'accounts:add';
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {Account} account
|
||||
*
|
||||
* @returns {object} - action definition
|
||||
*/
|
||||
export function add(account: Account): AddAction {
|
||||
return {
|
||||
type: ADD,
|
||||
type: 'accounts:add',
|
||||
payload: account,
|
||||
};
|
||||
}
|
||||
|
||||
export const REMOVE = 'accounts:remove';
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {Account} account
|
||||
*
|
||||
* @returns {object} - action definition
|
||||
*/
|
||||
interface RemoveAction extends ReduxAction {
|
||||
type: 'accounts:remove';
|
||||
payload: Account;
|
||||
}
|
||||
|
||||
export function remove(account: Account): RemoveAction {
|
||||
return {
|
||||
type: REMOVE,
|
||||
type: 'accounts:remove',
|
||||
payload: account,
|
||||
};
|
||||
}
|
||||
|
||||
export const ACTIVATE = 'accounts:activate';
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @param {Account} account
|
||||
*
|
||||
* @returns {object} - action definition
|
||||
*/
|
||||
interface ActivateAction extends ReduxAction {
|
||||
type: 'accounts:activate';
|
||||
payload: Account;
|
||||
}
|
||||
|
||||
export function activate(account: Account): ActivateAction {
|
||||
return {
|
||||
type: ACTIVATE,
|
||||
type: 'accounts:activate',
|
||||
payload: account,
|
||||
};
|
||||
}
|
||||
|
||||
export const RESET = 'accounts:reset';
|
||||
/**
|
||||
* @private
|
||||
*
|
||||
* @returns {object} - action definition
|
||||
*/
|
||||
interface ResetAction extends ReduxAction {
|
||||
type: 'accounts:reset';
|
||||
}
|
||||
|
||||
export function reset(): ResetAction {
|
||||
return {
|
||||
type: RESET,
|
||||
type: 'accounts:reset',
|
||||
};
|
||||
}
|
||||
|
||||
export const UPDATE_TOKEN = 'accounts:updateToken';
|
||||
/**
|
||||
* @param {string} token
|
||||
*
|
||||
* @returns {object} - action definition
|
||||
*/
|
||||
interface UpdateTokenAction extends ReduxAction {
|
||||
type: 'accounts:updateToken';
|
||||
payload: string;
|
||||
}
|
||||
|
||||
export function updateToken(token: string): UpdateTokenAction {
|
||||
return {
|
||||
type: UPDATE_TOKEN,
|
||||
type: 'accounts:updateToken',
|
||||
payload: token,
|
||||
};
|
||||
}
|
||||
|
||||
export type Action =
|
||||
| AddAction
|
||||
| RemoveAction
|
||||
| ActivateAction
|
||||
| ResetAction
|
||||
| UpdateTokenAction;
|
||||
|
@ -1,17 +1,8 @@
|
||||
import expect from 'app/test/unexpected';
|
||||
|
||||
import { updateToken } from './actions';
|
||||
import {
|
||||
add,
|
||||
remove,
|
||||
activate,
|
||||
reset,
|
||||
ADD,
|
||||
REMOVE,
|
||||
ACTIVATE,
|
||||
UPDATE_TOKEN,
|
||||
RESET,
|
||||
} from './actions/pure-actions';
|
||||
import { add, remove, activate, reset } from './actions/pure-actions';
|
||||
import { AccountsState } from './index';
|
||||
import accounts, { Account } from './reducer';
|
||||
|
||||
const account: Account = {
|
||||
@ -22,7 +13,7 @@ const account: Account = {
|
||||
} as Account;
|
||||
|
||||
describe('Accounts reducer', () => {
|
||||
let initial;
|
||||
let initial: AccountsState;
|
||||
|
||||
beforeEach(() => {
|
||||
initial = accounts(undefined, {} as any);
|
||||
@ -39,7 +30,7 @@ describe('Accounts reducer', () => {
|
||||
state: 'foo',
|
||||
}));
|
||||
|
||||
describe(ACTIVATE, () => {
|
||||
describe('accounts:activate', () => {
|
||||
it('sets active account', () => {
|
||||
expect(accounts(initial, activate(account)), 'to satisfy', {
|
||||
active: account.id,
|
||||
@ -47,7 +38,7 @@ describe('Accounts reducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(ADD, () => {
|
||||
describe('accounts:add', () => {
|
||||
it('adds an account', () =>
|
||||
expect(accounts(initial, add(account)), 'to satisfy', {
|
||||
available: [account],
|
||||
@ -106,7 +97,7 @@ describe('Accounts reducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(REMOVE, () => {
|
||||
describe('accounts:remove', () => {
|
||||
it('should remove an account', () =>
|
||||
expect(
|
||||
accounts({ ...initial, available: [account] }, remove(account)),
|
||||
@ -128,7 +119,7 @@ describe('Accounts reducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(RESET, () => {
|
||||
describe('actions:reset', () => {
|
||||
it('should reset accounts state', () =>
|
||||
expect(
|
||||
accounts({ ...initial, available: [account] }, reset()),
|
||||
@ -137,7 +128,7 @@ describe('Accounts reducer', () => {
|
||||
));
|
||||
});
|
||||
|
||||
describe(UPDATE_TOKEN, () => {
|
||||
describe('accounts:updateToken', () => {
|
||||
it('should update token', () => {
|
||||
const newToken = 'newToken';
|
||||
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { Action } from './actions/pure-actions';
|
||||
|
||||
export type Account = {
|
||||
id: number;
|
||||
username: string;
|
||||
@ -8,25 +10,9 @@ export type Account = {
|
||||
|
||||
export type State = {
|
||||
active: number | null;
|
||||
available: Account[];
|
||||
available: Array<Account>;
|
||||
};
|
||||
|
||||
export type AddAction = { type: 'accounts:add'; payload: Account };
|
||||
export type RemoveAction = { type: 'accounts:remove'; payload: Account };
|
||||
export type ActivateAction = { type: 'accounts:activate'; payload: Account };
|
||||
export type UpdateTokenAction = {
|
||||
type: 'accounts:updateToken';
|
||||
payload: string;
|
||||
};
|
||||
export type ResetAction = { type: 'accounts:reset' };
|
||||
|
||||
type Action =
|
||||
| AddAction
|
||||
| RemoveAction
|
||||
| ActivateAction
|
||||
| UpdateTokenAction
|
||||
| ResetAction;
|
||||
|
||||
export function getActiveAccount(state: { accounts: State }): Account | null {
|
||||
const accountId = state.accounts.active;
|
||||
|
||||
|
@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import AuthError from 'app/components/auth/authError/AuthError';
|
||||
import { FormModel } from 'app/components/ui/form';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import Context, { AuthContext } from './Context';
|
||||
|
||||
@ -11,7 +12,7 @@ import Context, { AuthContext } from './Context';
|
||||
|
||||
class BaseAuthBody extends React.Component<
|
||||
// TODO: this may be converted to generic type RouteComponentProps<T>
|
||||
RouteComponentProps<{ [key: string]: any }>
|
||||
RouteComponentProps<Record<string, any>>
|
||||
> {
|
||||
static contextType = Context;
|
||||
/* TODO: use declare */ context: React.ContextType<typeof Context>;
|
||||
@ -32,10 +33,14 @@ class BaseAuthBody extends React.Component<
|
||||
this.prevErrors = this.context.auth.error;
|
||||
}
|
||||
|
||||
renderErrors() {
|
||||
renderErrors(): ReactNode {
|
||||
const error = this.form.getFirstError();
|
||||
|
||||
return error && <AuthError error={error} onClose={this.onClearErrors} />;
|
||||
if (error === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <AuthError error={error} onClose={this.onClearErrors} />;
|
||||
}
|
||||
|
||||
onFormSubmit() {
|
||||
|
@ -1,8 +1,15 @@
|
||||
import React from 'react';
|
||||
import React, { CSSProperties, MouseEventHandler, ReactElement, ReactNode } from 'react';
|
||||
import { AccountsState } from 'app/components/accounts';
|
||||
import { User } from 'app/components/user';
|
||||
import { connect } from 'react-redux';
|
||||
import { TransitionMotion, spring } from 'react-motion';
|
||||
import {
|
||||
TransitionMotion,
|
||||
spring,
|
||||
PlainStyle,
|
||||
Style,
|
||||
TransitionStyle,
|
||||
TransitionPlainStyle,
|
||||
} from 'react-motion';
|
||||
import {
|
||||
Panel,
|
||||
PanelBody,
|
||||
@ -44,7 +51,7 @@ type PanelId = string;
|
||||
* - 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[]> = [
|
||||
const contexts: Array<Array<PanelId>> = [
|
||||
['login', 'password', 'forgotPassword', 'mfa', 'recoverPassword'],
|
||||
['register', 'activation', 'resendActivation'],
|
||||
['acceptRules'],
|
||||
@ -70,40 +77,41 @@ if (process.env.NODE_ENV !== 'production') {
|
||||
});
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}, {} as Record<string, Array<PanelId>>);
|
||||
}
|
||||
|
||||
type ValidationError =
|
||||
| string
|
||||
| {
|
||||
type: string;
|
||||
payload: { [key: string]: any };
|
||||
payload: Record<string, any>;
|
||||
};
|
||||
|
||||
type AnimationProps = {
|
||||
interface AnimationStyle extends PlainStyle {
|
||||
opacitySpring: number;
|
||||
transformSpring: number;
|
||||
};
|
||||
}
|
||||
|
||||
type AnimationContext = {
|
||||
interface AnimationData {
|
||||
Title: ReactElement;
|
||||
Body: ReactElement;
|
||||
Footer: ReactElement;
|
||||
Links: ReactNode;
|
||||
hasBackButton: boolean | ((props: Props) => boolean);
|
||||
}
|
||||
|
||||
interface AnimationContext extends TransitionPlainStyle {
|
||||
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);
|
||||
};
|
||||
};
|
||||
style: AnimationStyle;
|
||||
data: AnimationData;
|
||||
}
|
||||
|
||||
type OwnProps = {
|
||||
Title: React.ReactElement<any>;
|
||||
Body: React.ReactElement<any>;
|
||||
Footer: React.ReactElement<any>;
|
||||
Links: React.ReactElement<any>;
|
||||
children?: React.ReactElement<any>;
|
||||
};
|
||||
interface OwnProps {
|
||||
Title: ReactElement;
|
||||
Body: ReactElement;
|
||||
Footer: ReactElement;
|
||||
Links: ReactNode;
|
||||
}
|
||||
|
||||
interface Props extends OwnProps {
|
||||
// context props
|
||||
@ -114,17 +122,18 @@ interface Props extends OwnProps {
|
||||
resolve: () => void;
|
||||
reject: () => void;
|
||||
|
||||
setErrors: (errors: { [key: string]: ValidationError }) => void;
|
||||
setErrors: (errors: Record<string, ValidationError>) => void;
|
||||
}
|
||||
|
||||
type State = {
|
||||
interface State {
|
||||
contextHeight: number;
|
||||
panelId: PanelId | void;
|
||||
prevPanelId: PanelId | void;
|
||||
isHeightDirty: boolean;
|
||||
forceHeight: 1 | 0;
|
||||
direction: 'X' | 'Y';
|
||||
};
|
||||
formsHeights: Record<PanelId, number>;
|
||||
}
|
||||
|
||||
class PanelTransition extends React.PureComponent<Props, State> {
|
||||
state: State = {
|
||||
@ -134,16 +143,17 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
forceHeight: 0 as const,
|
||||
direction: 'X' as const,
|
||||
prevPanelId: undefined,
|
||||
formsHeights: {},
|
||||
};
|
||||
|
||||
isHeightMeasured: boolean = false;
|
||||
wasAutoFocused: boolean = false;
|
||||
body: null | {
|
||||
body: {
|
||||
autoFocus: () => void;
|
||||
onFormSubmit: () => void;
|
||||
} = null;
|
||||
} | null = null;
|
||||
|
||||
timerIds: NodeJS.Timeout[] = []; // this is a list of a probably running timeouts to clean on unmount
|
||||
timerIds: Array<number> = []; // this is a list of a probably running timeouts to clean on unmount
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
const nextPanel: PanelId =
|
||||
@ -166,7 +176,8 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
|
||||
if (forceHeight) {
|
||||
this.timerIds.push(
|
||||
setTimeout(() => {
|
||||
// https://stackoverflow.com/a/51040768/5184751
|
||||
window.setTimeout(() => {
|
||||
this.setState({ forceHeight: 0 });
|
||||
}, 100),
|
||||
);
|
||||
@ -208,7 +219,7 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
hasGoBack: boolean;
|
||||
} = Body.type as any;
|
||||
|
||||
const formHeight = this.state[`formHeight${panelId}`] || 0;
|
||||
const formHeight = this.state.formsHeights[panelId] || 0;
|
||||
|
||||
// a hack to disable height animation on first render
|
||||
const { isHeightMeasured } = this;
|
||||
@ -310,7 +321,7 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
onFormSubmit = () => {
|
||||
onFormSubmit = (): void => {
|
||||
this.props.clearErrors();
|
||||
|
||||
if (this.body) {
|
||||
@ -318,29 +329,28 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
}
|
||||
};
|
||||
|
||||
onFormInvalid = (errors: { [key: string]: ValidationError }) =>
|
||||
onFormInvalid = (errors: Record<string, ValidationError>): void =>
|
||||
this.props.setErrors(errors);
|
||||
|
||||
willEnter = (config: AnimationContext) => this.getTransitionStyles(config);
|
||||
willLeave = (config: AnimationContext) =>
|
||||
this.getTransitionStyles(config, { isLeave: true });
|
||||
willEnter = (config: TransitionStyle): PlainStyle => {
|
||||
const transform = this.getTransformForPanel(config.key);
|
||||
|
||||
/**
|
||||
* @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;
|
||||
return {
|
||||
transformSpring: transform,
|
||||
opacitySpring: 1,
|
||||
};
|
||||
};
|
||||
|
||||
willLeave = (config: TransitionStyle): Style => {
|
||||
const transform = this.getTransformForPanel(config.key);
|
||||
|
||||
return {
|
||||
transformSpring: spring(transform, transformSpringConfig),
|
||||
opacitySpring: spring(0, opacitySpringConfig),
|
||||
};
|
||||
};
|
||||
|
||||
getTransformForPanel(key: PanelId): number {
|
||||
const { panelId, prevPanelId } = this.state;
|
||||
|
||||
const fromLeft = -1;
|
||||
@ -363,14 +373,7 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
sign *= -1;
|
||||
}
|
||||
|
||||
const transform = sign * 100;
|
||||
|
||||
return {
|
||||
transformSpring: isLeave
|
||||
? spring(transform, transformSpringConfig)
|
||||
: transform,
|
||||
opacitySpring: isLeave ? spring(0, opacitySpringConfig) : 1,
|
||||
};
|
||||
return sign * 100;
|
||||
}
|
||||
|
||||
getDirection(next: PanelId, prev: PanelId): 'X' | 'Y' {
|
||||
@ -383,24 +386,23 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
return context.includes(next) ? 'X' : 'Y';
|
||||
}
|
||||
|
||||
onUpdateHeight = (height: number, key: PanelId) => {
|
||||
const heightKey = `formHeight${key}`;
|
||||
|
||||
// @ts-ignore
|
||||
onUpdateHeight = (height: number, key: PanelId): void => {
|
||||
this.setState({
|
||||
[heightKey]: height,
|
||||
formsHeights: {
|
||||
...this.state.formsHeights,
|
||||
[key]: height,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
onUpdateContextHeight = (height: number) => {
|
||||
onUpdateContextHeight = (height: number): void => {
|
||||
this.setState({
|
||||
contextHeight: height,
|
||||
});
|
||||
};
|
||||
|
||||
onGoBack = (event: React.MouseEvent<HTMLElement>) => {
|
||||
onGoBack: MouseEventHandler = (event): void => {
|
||||
event.preventDefault();
|
||||
|
||||
authFlow.goBack();
|
||||
};
|
||||
|
||||
@ -409,7 +411,7 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
*
|
||||
* @param {number} length number of panels transitioned
|
||||
*/
|
||||
tryToAutoFocus(length: number) {
|
||||
tryToAutoFocus(length: number): void {
|
||||
if (!this.body) {
|
||||
return;
|
||||
}
|
||||
@ -425,20 +427,17 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
}
|
||||
}
|
||||
|
||||
shouldMeasureHeight() {
|
||||
shouldMeasureHeight(): string {
|
||||
const { user, accounts, auth } = this.props;
|
||||
const { isHeightDirty } = this.state;
|
||||
|
||||
const errorString = Object.values(auth.error || {}).reduce(
|
||||
(acc: string, item: ValidationError): string => {
|
||||
if (typeof item === 'string') {
|
||||
return acc + item;
|
||||
}
|
||||
const errorString = Object.values(auth.error || {}).reduce((acc, item) => {
|
||||
if (typeof item === 'string') {
|
||||
return acc + item;
|
||||
}
|
||||
|
||||
return acc + item.type;
|
||||
},
|
||||
'',
|
||||
) as string;
|
||||
return acc + item.type;
|
||||
}, '') as string;
|
||||
|
||||
return [
|
||||
errorString,
|
||||
@ -448,9 +447,9 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
].join('');
|
||||
}
|
||||
|
||||
getHeader({ key, style, data }: AnimationContext) {
|
||||
const { Title } = data;
|
||||
const { transformSpring } = style;
|
||||
getHeader({ key, style, data }: TransitionPlainStyle): ReactElement {
|
||||
const { Title } = data as AnimationData;
|
||||
const { transformSpring } = (style as unknown) as AnimationStyle;
|
||||
|
||||
let { hasBackButton } = data;
|
||||
|
||||
@ -459,7 +458,10 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
const transitionStyle = {
|
||||
...this.getDefaultTransitionStyles(key, style),
|
||||
...this.getDefaultTransitionStyles(
|
||||
key,
|
||||
(style as unknown) as AnimationStyle,
|
||||
),
|
||||
opacity: 1, // reset default
|
||||
};
|
||||
|
||||
@ -491,15 +493,12 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
getBody({ key, style, data }: AnimationContext) {
|
||||
const { Body } = data;
|
||||
const { transformSpring } = style;
|
||||
getBody({ key, style, data }: TransitionPlainStyle): ReactElement {
|
||||
const { Body } = data as AnimationData;
|
||||
const { transformSpring } = (style as unknown) as AnimationStyle;
|
||||
const { direction } = this.state;
|
||||
|
||||
let transform: { [key: string]: string } = this.translate(
|
||||
transformSpring,
|
||||
direction,
|
||||
);
|
||||
let transform = this.translate(transformSpring, direction);
|
||||
let verticalOrigin = 'top';
|
||||
|
||||
if (direction === 'Y') {
|
||||
@ -507,8 +506,11 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
transform = {};
|
||||
}
|
||||
|
||||
const transitionStyle = {
|
||||
...this.getDefaultTransitionStyles(key, style),
|
||||
const transitionStyle: CSSProperties = {
|
||||
...this.getDefaultTransitionStyles(
|
||||
key,
|
||||
(style as unknown) as AnimationStyle,
|
||||
),
|
||||
top: 'auto', // reset default
|
||||
[verticalOrigin]: 0,
|
||||
...transform,
|
||||
@ -522,6 +524,7 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
onMeasure={height => this.onUpdateHeight(height, key)}
|
||||
>
|
||||
{React.cloneElement(Body, {
|
||||
// @ts-ignore
|
||||
ref: body => {
|
||||
this.body = body;
|
||||
},
|
||||
@ -530,10 +533,13 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
getFooter({ key, style, data }: AnimationContext) {
|
||||
const { Footer } = data;
|
||||
getFooter({ key, style, data }: TransitionPlainStyle): ReactElement {
|
||||
const { Footer } = data as AnimationData;
|
||||
|
||||
const transitionStyle = this.getDefaultTransitionStyles(key, style);
|
||||
const transitionStyle = this.getDefaultTransitionStyles(
|
||||
key,
|
||||
(style as unknown) as AnimationStyle,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={`footer/${key}`} style={transitionStyle}>
|
||||
@ -542,10 +548,13 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
getLinks({ key, style, data }: AnimationContext) {
|
||||
const { Links } = data;
|
||||
getLinks({ key, style, data }: TransitionPlainStyle): ReactElement {
|
||||
const { Links } = data as AnimationData;
|
||||
|
||||
const transitionStyle = this.getDefaultTransitionStyles(key, style);
|
||||
const transitionStyle = this.getDefaultTransitionStyles(
|
||||
key,
|
||||
(style as unknown) as AnimationStyle,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={`links/${key}`} style={transitionStyle}>
|
||||
@ -554,16 +563,9 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {object} style
|
||||
* @param {number} style.opacitySpring
|
||||
*
|
||||
* @returns {object}
|
||||
*/
|
||||
getDefaultTransitionStyles(
|
||||
key: string,
|
||||
{ opacitySpring }: Readonly<AnimationProps>,
|
||||
{ opacitySpring }: Readonly<AnimationStyle>,
|
||||
): {
|
||||
position: 'absolute';
|
||||
top: number;
|
||||
@ -582,7 +584,11 @@ class PanelTransition extends React.PureComponent<Props, State> {
|
||||
};
|
||||
}
|
||||
|
||||
translate(value: number, direction: 'X' | 'Y' = 'X', unit: '%' | 'px' = '%') {
|
||||
translate(
|
||||
value: number,
|
||||
direction: 'X' | 'Y' = 'X',
|
||||
unit: '%' | 'px' = '%',
|
||||
): CSSProperties {
|
||||
return {
|
||||
WebkitTransform: `translate${direction}(${value}${unit})`,
|
||||
transform: `translate${direction}(${value}${unit})`,
|
||||
|
@ -1,20 +1,22 @@
|
||||
import React, { useContext } from 'react';
|
||||
import React, { ComponentType, useContext } from 'react';
|
||||
import { FormattedMessage as Message, MessageDescriptor } from 'react-intl';
|
||||
|
||||
import Context, { AuthContext } from './Context';
|
||||
|
||||
interface Props {
|
||||
isAvailable?: (context: AuthContext) => boolean;
|
||||
payload?: { [key: string]: any };
|
||||
payload?: Record<string, any>;
|
||||
label: MessageDescriptor;
|
||||
}
|
||||
|
||||
export type RejectionLinkProps = Props;
|
||||
|
||||
function RejectionLink(props: Props) {
|
||||
const RejectionLink: ComponentType<Props> = ({
|
||||
isAvailable,
|
||||
payload,
|
||||
label,
|
||||
}) => {
|
||||
const context = useContext(Context);
|
||||
|
||||
if (props.isAvailable && !props.isAvailable(context)) {
|
||||
if (isAvailable && !isAvailable(context)) {
|
||||
// TODO: if want to properly support multiple links, we should control
|
||||
// the dividers ' | ' rendered from factory too
|
||||
return null;
|
||||
@ -26,12 +28,12 @@ function RejectionLink(props: Props) {
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
|
||||
context.reject(props.payload);
|
||||
context.reject(payload);
|
||||
}}
|
||||
>
|
||||
<Message {...props.label} />
|
||||
<Message {...label} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default RejectionLink;
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { Action as ReduxAction } from 'redux';
|
||||
import sinon from 'sinon';
|
||||
import expect from 'app/test/unexpected';
|
||||
|
||||
@ -15,33 +16,36 @@ import {
|
||||
login,
|
||||
setLogin,
|
||||
} from 'app/components/auth/actions';
|
||||
import { OauthData, OAuthValidateResponse } from '../../services/api/oauth';
|
||||
|
||||
const oauthData = {
|
||||
const oauthData: OauthData = {
|
||||
clientId: '',
|
||||
redirectUrl: '',
|
||||
responseType: '',
|
||||
scope: '',
|
||||
state: '',
|
||||
prompt: 'none',
|
||||
};
|
||||
|
||||
describe('components/auth/actions', () => {
|
||||
const dispatch = sinon.stub().named('store.dispatch');
|
||||
const getState = sinon.stub().named('store.getState');
|
||||
|
||||
function callThunk(fn, ...args) {
|
||||
function callThunk<A extends Array<any>, F extends (...args: A) => any>(
|
||||
fn: F,
|
||||
...args: A
|
||||
): Promise<void> {
|
||||
const thunk = fn(...args);
|
||||
|
||||
return thunk(dispatch, getState);
|
||||
}
|
||||
|
||||
function expectDispatchCalls(calls) {
|
||||
expect(
|
||||
dispatch,
|
||||
'to have calls satisfying',
|
||||
[[setLoadingState(true)]]
|
||||
.concat(calls)
|
||||
.concat([[setLoadingState(false)]]),
|
||||
);
|
||||
function expectDispatchCalls(calls: Array<Array<ReduxAction>>) {
|
||||
expect(dispatch, 'to have calls satisfying', [
|
||||
[setLoadingState(true)],
|
||||
...calls,
|
||||
[setLoadingState(false)],
|
||||
]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@ -58,14 +62,20 @@ describe('components/auth/actions', () => {
|
||||
});
|
||||
|
||||
describe('#oAuthValidate()', () => {
|
||||
let resp;
|
||||
let resp: OAuthValidateResponse;
|
||||
|
||||
beforeEach(() => {
|
||||
resp = {
|
||||
client: { id: 123 },
|
||||
oAuth: { state: 123 },
|
||||
client: {
|
||||
id: '123',
|
||||
name: '',
|
||||
description: '',
|
||||
},
|
||||
oAuth: {
|
||||
state: 123,
|
||||
},
|
||||
session: {
|
||||
scopes: ['scopes'],
|
||||
scopes: ['account_info'],
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Action as ReduxAction } from 'redux';
|
||||
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 * as loader from 'app/services/loader';
|
||||
import history from 'app/services/history';
|
||||
import {
|
||||
updateUser,
|
||||
@ -15,22 +16,27 @@ import {
|
||||
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 oauth, { OauthData, Scope } from 'app/services/api/oauth';
|
||||
import {
|
||||
register as registerEndpoint,
|
||||
activate as activateEndpoint,
|
||||
resendActivation as resendActivationEndpoint,
|
||||
} 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 { Account } from 'app/components/accounts/reducer';
|
||||
import { ThunkAction, Dispatch } from 'app/reducers';
|
||||
import { Resp } from 'app/services/request';
|
||||
|
||||
import { getCredentials } from './reducer';
|
||||
import { Credentials, Client, OAuthState, getCredentials } from './reducer';
|
||||
|
||||
type ValidationError =
|
||||
| string
|
||||
| {
|
||||
type: string;
|
||||
payload: { [key: string]: any };
|
||||
};
|
||||
interface ValidationErrorLiteral {
|
||||
type: string;
|
||||
payload: Record<string, any>;
|
||||
}
|
||||
|
||||
type ValidationError = string | ValidationErrorLiteral;
|
||||
|
||||
/**
|
||||
* Routes user to the previous page if it is possible
|
||||
@ -169,16 +175,15 @@ export function register({
|
||||
rulesAgreement: boolean;
|
||||
}) {
|
||||
return wrapInLoader((dispatch, getState) =>
|
||||
signup
|
||||
.register({
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
rePassword,
|
||||
rulesAgreement,
|
||||
lang: getState().user.lang,
|
||||
captcha,
|
||||
})
|
||||
registerEndpoint({
|
||||
email,
|
||||
username,
|
||||
password,
|
||||
rePassword,
|
||||
rulesAgreement,
|
||||
lang: getState().user.lang,
|
||||
captcha,
|
||||
})
|
||||
.then(() => {
|
||||
dispatch(
|
||||
updateUser({
|
||||
@ -201,8 +206,7 @@ export function activate({
|
||||
key: string;
|
||||
}): ThunkAction<Promise<Account>> {
|
||||
return wrapInLoader(dispatch =>
|
||||
signup
|
||||
.activate({ key })
|
||||
activateEndpoint(key)
|
||||
.then(authHandler(dispatch))
|
||||
.catch(validationErrorsHandler(dispatch, '/resend-activation')),
|
||||
);
|
||||
@ -216,8 +220,7 @@ export function resendActivation({
|
||||
captcha: string;
|
||||
}) {
|
||||
return wrapInLoader(dispatch =>
|
||||
signup
|
||||
.resendActivation({ email, captcha })
|
||||
resendActivationEndpoint(email, captcha)
|
||||
.then(resp => {
|
||||
dispatch(
|
||||
updateUser({
|
||||
@ -235,25 +238,26 @@ export function contactUs() {
|
||||
return createPopup({ Popup: ContactForm });
|
||||
}
|
||||
|
||||
export const SET_CREDENTIALS = 'auth:setCredentials';
|
||||
interface SetCredentialsAction extends ReduxAction {
|
||||
type: 'auth:setCredentials';
|
||||
payload: Credentials | null;
|
||||
}
|
||||
|
||||
function setCredentials(payload: Credentials | null): SetCredentialsAction {
|
||||
return {
|
||||
type: 'auth:setCredentials',
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets login in credentials state
|
||||
*
|
||||
* Resets the state, when `null` is passed
|
||||
*
|
||||
* @param {string|null} login
|
||||
*
|
||||
* @returns {object}
|
||||
* @param login
|
||||
*/
|
||||
export function setLogin(login: string | null) {
|
||||
return {
|
||||
type: SET_CREDENTIALS,
|
||||
payload: login
|
||||
? {
|
||||
login,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
export function setLogin(login: string | null): SetCredentialsAction {
|
||||
return setCredentials(login ? { login } : null);
|
||||
}
|
||||
|
||||
export function relogin(login: string | null): ThunkAction {
|
||||
@ -262,19 +266,20 @@ export function relogin(login: string | null): ThunkAction {
|
||||
const returnUrl =
|
||||
credentials.returnUrl || location.pathname + location.search;
|
||||
|
||||
dispatch({
|
||||
type: SET_CREDENTIALS,
|
||||
payload: {
|
||||
dispatch(
|
||||
setCredentials({
|
||||
login,
|
||||
returnUrl,
|
||||
isRelogin: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
browserHistory.push('/login');
|
||||
};
|
||||
}
|
||||
|
||||
export type CredentialsAction = SetCredentialsAction;
|
||||
|
||||
function requestTotp({
|
||||
login,
|
||||
password,
|
||||
@ -288,41 +293,55 @@ function requestTotp({
|
||||
// merging with current credentials to propogate returnUrl
|
||||
const credentials = getCredentials(getState());
|
||||
|
||||
dispatch({
|
||||
type: SET_CREDENTIALS,
|
||||
payload: {
|
||||
dispatch(
|
||||
setCredentials({
|
||||
...credentials,
|
||||
login,
|
||||
password,
|
||||
rememberMe,
|
||||
isTotpRequired: true,
|
||||
},
|
||||
});
|
||||
}),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const SET_SWITCHER = 'auth:setAccountSwitcher';
|
||||
export function setAccountSwitcher(isOn: boolean) {
|
||||
interface SetSwitcherAction extends ReduxAction {
|
||||
type: 'auth:setAccountSwitcher';
|
||||
payload: boolean;
|
||||
}
|
||||
|
||||
export function setAccountSwitcher(isOn: boolean): SetSwitcherAction {
|
||||
return {
|
||||
type: SET_SWITCHER,
|
||||
type: 'auth:setAccountSwitcher',
|
||||
payload: isOn,
|
||||
};
|
||||
}
|
||||
|
||||
export const ERROR = 'auth:error';
|
||||
export function setErrors(errors: { [key: string]: ValidationError } | null) {
|
||||
export type AccountSwitcherAction = SetSwitcherAction;
|
||||
|
||||
interface SetErrorAction extends ReduxAction {
|
||||
type: 'auth:error';
|
||||
payload: Record<string, ValidationError> | null;
|
||||
error: boolean;
|
||||
}
|
||||
|
||||
export function setErrors(
|
||||
errors: Record<string, ValidationError> | null,
|
||||
): SetErrorAction {
|
||||
return {
|
||||
type: ERROR,
|
||||
type: 'auth:error',
|
||||
payload: errors,
|
||||
error: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function clearErrors() {
|
||||
export function clearErrors(): SetErrorAction {
|
||||
return setErrors(null);
|
||||
}
|
||||
|
||||
const KNOWN_SCOPES = [
|
||||
export type ErrorAction = SetErrorAction;
|
||||
|
||||
const KNOWN_SCOPES: ReadonlyArray<string> = [
|
||||
'minecraft_server_session',
|
||||
'offline_access',
|
||||
'account_info',
|
||||
@ -470,11 +489,76 @@ function handleOauthParamsValidation(
|
||||
return Promise.reject(resp);
|
||||
}
|
||||
|
||||
export const SET_CLIENT = 'set_client';
|
||||
export function setClient({ id, name, description }: Client) {
|
||||
interface SetClientAction extends ReduxAction {
|
||||
type: 'set_client';
|
||||
payload: Client;
|
||||
}
|
||||
|
||||
export function setClient(payload: Client): SetClientAction {
|
||||
return {
|
||||
type: SET_CLIENT,
|
||||
payload: { id, name, description },
|
||||
type: 'set_client',
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
export type ClientAction = SetClientAction;
|
||||
|
||||
interface SetOauthAction extends ReduxAction {
|
||||
type: 'set_oauth';
|
||||
payload: Pick<
|
||||
OAuthState,
|
||||
| 'clientId'
|
||||
| 'redirectUrl'
|
||||
| 'responseType'
|
||||
| 'scope'
|
||||
| 'prompt'
|
||||
| 'loginHint'
|
||||
| 'state'
|
||||
>;
|
||||
}
|
||||
|
||||
// Input data is coming right from the query string, so the names
|
||||
// are the same, as used for initializing OAuth2 request
|
||||
export function setOAuthRequest(data: {
|
||||
client_id?: string;
|
||||
redirect_uri?: string;
|
||||
response_type?: string;
|
||||
scope?: string;
|
||||
prompt?: string;
|
||||
loginHint?: string;
|
||||
state?: string;
|
||||
}): SetOauthAction {
|
||||
return {
|
||||
type: 'set_oauth',
|
||||
payload: {
|
||||
// TODO: there is too much default empty string. Maybe we can somehow validate it
|
||||
// on the level, where this action is called?
|
||||
clientId: data.client_id || '',
|
||||
redirectUrl: data.redirect_uri || '',
|
||||
responseType: data.response_type || '',
|
||||
scope: data.scope || '',
|
||||
prompt: data.prompt || '',
|
||||
loginHint: data.loginHint || '',
|
||||
state: data.state || '',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface SetOAuthResultAction extends ReduxAction {
|
||||
type: 'set_oauth_result';
|
||||
payload: Pick<OAuthState, 'success' | 'code' | 'displayCode'>;
|
||||
}
|
||||
|
||||
export const SET_OAUTH_RESULT = 'set_oauth_result'; // TODO: remove
|
||||
|
||||
export function setOAuthCode(payload: {
|
||||
success: boolean;
|
||||
code: string;
|
||||
displayCode: boolean;
|
||||
}): SetOAuthResultAction {
|
||||
return {
|
||||
type: 'set_oauth_result',
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
@ -507,69 +591,43 @@ export function resetAuth(): ThunkAction {
|
||||
};
|
||||
}
|
||||
|
||||
export const SET_OAUTH = 'set_oauth';
|
||||
export function setOAuthRequest(data: {
|
||||
client_id?: string;
|
||||
redirect_uri?: string;
|
||||
response_type?: string;
|
||||
scope?: string;
|
||||
prompt?: string;
|
||||
loginHint?: string;
|
||||
state?: string;
|
||||
}) {
|
||||
interface RequestPermissionsAcceptAction extends ReduxAction {
|
||||
type: 'require_permissions_accept';
|
||||
}
|
||||
|
||||
export function requirePermissionsAccept(): RequestPermissionsAcceptAction {
|
||||
return {
|
||||
type: SET_OAUTH,
|
||||
payload: {
|
||||
clientId: data.client_id,
|
||||
redirectUrl: data.redirect_uri,
|
||||
responseType: data.response_type,
|
||||
scope: data.scope,
|
||||
prompt: data.prompt,
|
||||
loginHint: data.loginHint,
|
||||
state: data.state,
|
||||
},
|
||||
type: 'require_permissions_accept',
|
||||
};
|
||||
}
|
||||
|
||||
export const SET_OAUTH_RESULT = 'set_oauth_result';
|
||||
export function setOAuthCode(data: {
|
||||
success: boolean;
|
||||
code: string;
|
||||
displayCode: boolean;
|
||||
}) {
|
||||
export type OAuthAction =
|
||||
| SetOauthAction
|
||||
| SetOAuthResultAction
|
||||
| RequestPermissionsAcceptAction;
|
||||
|
||||
interface SetScopesAction extends ReduxAction {
|
||||
type: 'set_scopes';
|
||||
payload: Array<Scope>;
|
||||
}
|
||||
|
||||
export function setScopes(payload: Array<Scope>): SetScopesAction {
|
||||
return {
|
||||
type: SET_OAUTH_RESULT,
|
||||
payload: {
|
||||
success: data.success,
|
||||
code: data.code,
|
||||
displayCode: data.displayCode,
|
||||
},
|
||||
type: 'set_scopes',
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
export const REQUIRE_PERMISSIONS_ACCEPT = 'require_permissions_accept';
|
||||
export function requirePermissionsAccept() {
|
||||
return {
|
||||
type: REQUIRE_PERMISSIONS_ACCEPT,
|
||||
};
|
||||
export type ScopesAction = SetScopesAction;
|
||||
|
||||
interface SetLoadingAction extends ReduxAction {
|
||||
type: 'set_loading_state';
|
||||
payload: boolean;
|
||||
}
|
||||
|
||||
export const SET_SCOPES = 'set_scopes';
|
||||
export function setScopes(scopes: Scope[]) {
|
||||
if (!Array.isArray(scopes)) {
|
||||
throw new Error('Scopes must be array');
|
||||
}
|
||||
|
||||
export function setLoadingState(isLoading: boolean): SetLoadingAction {
|
||||
return {
|
||||
type: SET_SCOPES,
|
||||
payload: scopes,
|
||||
};
|
||||
}
|
||||
|
||||
export const SET_LOADING_STATE = 'set_loading_state';
|
||||
export function setLoadingState(isLoading: boolean) {
|
||||
return {
|
||||
type: SET_LOADING_STATE,
|
||||
type: 'set_loading_state',
|
||||
payload: isLoading,
|
||||
};
|
||||
}
|
||||
@ -594,6 +652,8 @@ function wrapInLoader<T>(fn: ThunkAction<Promise<T>>): ThunkAction<Promise<T>> {
|
||||
};
|
||||
}
|
||||
|
||||
export type LoadingAction = SetLoadingAction;
|
||||
|
||||
function needActivation() {
|
||||
return updateUser({
|
||||
isActive: false,
|
||||
@ -615,12 +675,20 @@ function authHandler(dispatch: Dispatch) {
|
||||
});
|
||||
}
|
||||
|
||||
function validationErrorsHandler(dispatch: Dispatch, repeatUrl?: string) {
|
||||
function validationErrorsHandler(
|
||||
dispatch: Dispatch,
|
||||
repeatUrl?: string,
|
||||
): (
|
||||
resp: Resp<{
|
||||
errors?: Record<string, string>;
|
||||
data?: Record<string, any>;
|
||||
}>,
|
||||
) => Promise<never> {
|
||||
return resp => {
|
||||
if (resp.errors) {
|
||||
const [firstError] = Object.keys(resp.errors);
|
||||
const error = {
|
||||
type: resp.errors[firstError],
|
||||
const firstErrorObj: ValidationError = {
|
||||
type: resp.errors[firstError] as string,
|
||||
payload: {
|
||||
isGuest: true,
|
||||
repeatUrl: '',
|
||||
@ -629,20 +697,24 @@ function validationErrorsHandler(dispatch: Dispatch, repeatUrl?: string) {
|
||||
|
||||
if (resp.data) {
|
||||
// TODO: this should be formatted on backend
|
||||
Object.assign(error.payload, resp.data);
|
||||
Object.assign(firstErrorObj.payload, resp.data);
|
||||
}
|
||||
|
||||
if (
|
||||
['error.key_not_exists', 'error.key_expire'].includes(error.type) &&
|
||||
['error.key_not_exists', 'error.key_expire'].includes(
|
||||
firstErrorObj.type,
|
||||
) &&
|
||||
repeatUrl
|
||||
) {
|
||||
// TODO: this should be formatted on backend
|
||||
error.payload.repeatUrl = repeatUrl;
|
||||
firstErrorObj.payload.repeatUrl = repeatUrl;
|
||||
}
|
||||
|
||||
resp.errors[firstError] = error;
|
||||
// TODO: can I clone the object or its necessary to catch modified errors list on corresponding catches?
|
||||
const errors: Record<string, ValidationError> = resp.errors;
|
||||
errors[firstError] = firstErrorObj;
|
||||
|
||||
dispatch(setErrors(resp.errors));
|
||||
dispatch(setErrors(errors));
|
||||
}
|
||||
|
||||
return Promise.reject(resp);
|
||||
|
@ -1,4 +1,3 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
@ -13,14 +12,6 @@ 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';
|
||||
|
@ -1,45 +0,0 @@
|
||||
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,
|
||||
};
|
45
packages/app/components/auth/authError/AuthError.tsx
Normal file
45
packages/app/components/auth/authError/AuthError.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
import React, { ComponentType, useEffect } from 'react';
|
||||
|
||||
import { resolve as resolveError } from 'app/services/errorsDict';
|
||||
import { PanelBodyHeader } from 'app/components/ui/Panel';
|
||||
import { ValidationError } from 'app/components/ui/form/FormModel';
|
||||
|
||||
interface Props {
|
||||
error: ValidationError;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
let autoHideTimer: number | null = null;
|
||||
function resetTimeout(): void {
|
||||
if (autoHideTimer) {
|
||||
clearTimeout(autoHideTimer);
|
||||
autoHideTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
const AuthError: ComponentType<Props> = ({ error, onClose }) => {
|
||||
useEffect(() => {
|
||||
resetTimeout();
|
||||
|
||||
if (
|
||||
onClose &&
|
||||
typeof error !== 'string' &&
|
||||
error.payload &&
|
||||
error.payload.canRepeatIn
|
||||
) {
|
||||
const msLeft = error.payload.canRepeatIn * 1000;
|
||||
// 1500 to let the user see, that time is elapsed
|
||||
setTimeout(onClose, msLeft - Date.now() + 1500);
|
||||
}
|
||||
|
||||
return resetTimeout;
|
||||
}, [error, onClose]);
|
||||
|
||||
return (
|
||||
<PanelBodyHeader type="error" onClose={onClose}>
|
||||
{resolveError(error)}
|
||||
</PanelBodyHeader>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthError;
|
@ -4,6 +4,7 @@ import { FormattedMessage as Message } from 'react-intl';
|
||||
|
||||
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
|
||||
import { AccountSwitcher } from 'app/components/accounts';
|
||||
import { Account } from 'app/components/accounts/reducer';
|
||||
|
||||
import styles from './chooseAccount.scss';
|
||||
import messages from './ChooseAccount.intl.json';
|
||||
@ -46,7 +47,7 @@ export default class ChooseAccountBody extends BaseAuthBody {
|
||||
);
|
||||
}
|
||||
|
||||
onSwitch = account => {
|
||||
onSwitch = (account: Account): void => {
|
||||
this.context.resolve(account);
|
||||
};
|
||||
}
|
@ -1,36 +1,36 @@
|
||||
import React from 'react';
|
||||
import React, { ComponentProps, ComponentType } from 'react';
|
||||
import { Button } from 'app/components/ui/form';
|
||||
import RejectionLink, {
|
||||
RejectionLinkProps,
|
||||
} from 'app/components/auth/RejectionLink';
|
||||
import RejectionLink from 'app/components/auth/RejectionLink';
|
||||
import AuthTitle from 'app/components/auth/AuthTitle';
|
||||
import { MessageDescriptor } from 'react-intl';
|
||||
import { Color } from 'app/components/ui';
|
||||
import BaseAuthBody from './BaseAuthBody';
|
||||
|
||||
/**
|
||||
* @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,
|
||||
}: {
|
||||
export type Factory = () => {
|
||||
Title: ComponentType;
|
||||
Body: typeof BaseAuthBody;
|
||||
Footer: ComponentType;
|
||||
Links: ComponentType;
|
||||
};
|
||||
|
||||
type RejectionLinkProps = ComponentProps<typeof RejectionLink>;
|
||||
interface FactoryParams {
|
||||
title: MessageDescriptor;
|
||||
body: React.ElementType;
|
||||
body: typeof BaseAuthBody;
|
||||
footer: {
|
||||
color?: Color;
|
||||
label: string | MessageDescriptor;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
links?: RejectionLinkProps | RejectionLinkProps[];
|
||||
}) {
|
||||
links?: RejectionLinkProps | Array<RejectionLinkProps>;
|
||||
}
|
||||
|
||||
export default function({
|
||||
title,
|
||||
body,
|
||||
footer,
|
||||
links,
|
||||
}: FactoryParams): Factory {
|
||||
return () => ({
|
||||
Title: () => <AuthTitle title={title} />,
|
||||
Body: body,
|
||||
@ -38,7 +38,7 @@ export default function({
|
||||
Links: () =>
|
||||
links ? (
|
||||
<span>
|
||||
{([] as RejectionLinkProps[])
|
||||
{([] as Array<RejectionLinkProps>)
|
||||
.concat(links)
|
||||
.map((link, index) => [
|
||||
index ? ' | ' : '',
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { MouseEventHandler } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
@ -13,7 +13,7 @@ interface Props {
|
||||
appName: string;
|
||||
code?: string;
|
||||
state: string;
|
||||
displayCode?: string;
|
||||
displayCode?: boolean;
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ class Finish extends React.Component<Props> {
|
||||
);
|
||||
}
|
||||
|
||||
onCopyClick = event => {
|
||||
onCopyClick: MouseEventHandler = event => {
|
||||
event.preventDefault();
|
||||
|
||||
const { code } = this.props;
|
||||
|
@ -1,13 +1,15 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Input } from 'app/components/ui/form';
|
||||
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
|
||||
import { User } from 'app/components/user/reducer';
|
||||
|
||||
import messages from './Login.intl.json';
|
||||
|
||||
export default class LoginBody extends BaseAuthBody {
|
||||
static displayName = 'LoginBody';
|
||||
static panelId = 'login';
|
||||
static hasGoBack = state => {
|
||||
static hasGoBack = (state: { user: User }) => {
|
||||
return !state.user.isGuest;
|
||||
};
|
||||
|
@ -1,4 +1,3 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
@ -16,14 +15,6 @@ export default class RecoverPasswordBody extends BaseAuthBody {
|
||||
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'
|
@ -1,14 +1,9 @@
|
||||
import expect from 'app/test/unexpected';
|
||||
import auth from './reducer';
|
||||
import {
|
||||
setLogin,
|
||||
SET_CREDENTIALS,
|
||||
setAccountSwitcher,
|
||||
SET_SWITCHER,
|
||||
} from './actions';
|
||||
import { setLogin, setAccountSwitcher } from './actions';
|
||||
|
||||
describe('components/auth/reducer', () => {
|
||||
describe(SET_CREDENTIALS, () => {
|
||||
describe('auth:setCredentials', () => {
|
||||
it('should set login', () => {
|
||||
const expectedLogin = 'foo';
|
||||
|
||||
@ -22,7 +17,7 @@ describe('components/auth/reducer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe(SET_SWITCHER, () => {
|
||||
describe('auth:setAccountSwitcher', () => {
|
||||
it('should be enabled by default', () =>
|
||||
expect(auth(undefined, {} as any), 'to satisfy', {
|
||||
isSwitcherEnabled: true,
|
||||
|
@ -1,26 +1,34 @@
|
||||
import { combineReducers } from 'redux';
|
||||
import { combineReducers, Reducer } from 'redux';
|
||||
import { RootState } from 'app/reducers';
|
||||
import { Scope } from '../../services/api/oauth';
|
||||
|
||||
import {
|
||||
ERROR,
|
||||
SET_CLIENT,
|
||||
SET_OAUTH,
|
||||
SET_OAUTH_RESULT,
|
||||
SET_SCOPES,
|
||||
SET_LOADING_STATE,
|
||||
REQUIRE_PERMISSIONS_ACCEPT,
|
||||
SET_CREDENTIALS,
|
||||
SET_SWITCHER,
|
||||
ErrorAction,
|
||||
CredentialsAction,
|
||||
AccountSwitcherAction,
|
||||
LoadingAction,
|
||||
ClientAction,
|
||||
OAuthAction,
|
||||
ScopesAction,
|
||||
} from './actions';
|
||||
|
||||
type Credentials = {
|
||||
login?: string;
|
||||
export interface Credentials {
|
||||
login?: string | null; // By some reasons there is can be null value. Need to investigate.
|
||||
password?: string;
|
||||
rememberMe?: boolean;
|
||||
returnUrl?: string;
|
||||
isRelogin?: boolean;
|
||||
isTotpRequired?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
type Error = Record<
|
||||
string,
|
||||
| string
|
||||
| {
|
||||
type: string;
|
||||
payload: Record<string, any>;
|
||||
}
|
||||
> | null;
|
||||
|
||||
export interface Client {
|
||||
id: string;
|
||||
@ -28,7 +36,7 @@ export interface Client {
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface OAuthState {
|
||||
export interface OAuthState {
|
||||
clientId: string;
|
||||
redirectUrl: string;
|
||||
responseType: string;
|
||||
@ -39,27 +47,113 @@ interface OAuthState {
|
||||
state: string;
|
||||
success?: boolean;
|
||||
code?: string;
|
||||
displayCode?: string;
|
||||
displayCode?: boolean;
|
||||
acceptRequired?: boolean;
|
||||
}
|
||||
|
||||
type Scopes = Array<Scope>;
|
||||
|
||||
export interface State {
|
||||
credentials: Credentials;
|
||||
error: null | {
|
||||
[key: string]:
|
||||
| string
|
||||
| {
|
||||
type: string;
|
||||
payload: { [key: string]: any };
|
||||
};
|
||||
};
|
||||
error: Error;
|
||||
isLoading: boolean;
|
||||
isSwitcherEnabled: boolean;
|
||||
client: Client | null;
|
||||
oauth: OAuthState | null;
|
||||
scopes: string[];
|
||||
scopes: Scopes;
|
||||
}
|
||||
|
||||
const error: Reducer<State['error'], ErrorAction> = (
|
||||
state = null,
|
||||
{ type, payload },
|
||||
) => {
|
||||
if (type === 'auth:error') {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const credentials: Reducer<State['credentials'], CredentialsAction> = (
|
||||
state = {},
|
||||
{ type, payload },
|
||||
) => {
|
||||
if (type === 'auth:setCredentials') {
|
||||
if (payload) {
|
||||
return {
|
||||
...payload,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const isSwitcherEnabled: Reducer<
|
||||
State['isSwitcherEnabled'],
|
||||
AccountSwitcherAction
|
||||
> = (state = true, { type, payload }) => {
|
||||
if (type === 'auth:setAccountSwitcher') {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const isLoading: Reducer<State['isLoading'], LoadingAction> = (
|
||||
state = false,
|
||||
{ type, payload },
|
||||
) => {
|
||||
if (type === 'set_loading_state') {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const client: Reducer<State['client'], ClientAction> = (
|
||||
state = null,
|
||||
{ type, payload },
|
||||
) => {
|
||||
if (type === 'set_client') {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
const oauth: Reducer<State['oauth'], OAuthAction> = (state = null, action) => {
|
||||
switch (action.type) {
|
||||
case 'set_oauth':
|
||||
return action.payload;
|
||||
case 'set_oauth_result':
|
||||
return {
|
||||
...(state as OAuthState),
|
||||
...action.payload,
|
||||
};
|
||||
case 'require_permissions_accept':
|
||||
return {
|
||||
...(state as OAuthState),
|
||||
acceptRequired: true,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const scopes: Reducer<State['scopes'], ScopesAction> = (
|
||||
state = [],
|
||||
{ type, payload },
|
||||
) => {
|
||||
if (type === 'set_scopes') {
|
||||
return payload;
|
||||
}
|
||||
|
||||
return state;
|
||||
};
|
||||
|
||||
export default combineReducers<State>({
|
||||
credentials,
|
||||
error,
|
||||
@ -70,135 +164,6 @@ export default combineReducers<State>({
|
||||
scopes,
|
||||
});
|
||||
|
||||
function error(
|
||||
state = null,
|
||||
{ type, payload = null, error = false },
|
||||
): State['error'] {
|
||||
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;
|
||||
},
|
||||
): State['credentials'] {
|
||||
if (type === SET_CREDENTIALS) {
|
||||
if (payload && typeof payload === 'object') {
|
||||
return {
|
||||
...payload,
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function isSwitcherEnabled(
|
||||
state = true,
|
||||
{ type, payload = false },
|
||||
): State['isSwitcherEnabled'] {
|
||||
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 },
|
||||
): State['isLoading'] {
|
||||
switch (type) {
|
||||
case SET_LOADING_STATE:
|
||||
return !!payload;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function client(state = null, { type, payload }): State['client'] {
|
||||
switch (type) {
|
||||
case SET_CLIENT:
|
||||
return {
|
||||
id: payload.id,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function oauth(
|
||||
state: State['oauth'] = null,
|
||||
{ type, payload },
|
||||
): State['oauth'] {
|
||||
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 as OAuthState),
|
||||
success: payload.success,
|
||||
code: payload.code,
|
||||
displayCode: payload.displayCode,
|
||||
};
|
||||
|
||||
case REQUIRE_PERMISSIONS_ACCEPT:
|
||||
return {
|
||||
...(state as OAuthState),
|
||||
acceptRequired: true,
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function scopes(state = [], { type, payload = [] }): State['scopes'] {
|
||||
switch (type) {
|
||||
case SET_SCOPES:
|
||||
return payload;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLogin(
|
||||
state: RootState | Pick<RootState, 'auth'>,
|
||||
): string | null {
|
||||
|
@ -1,17 +1,23 @@
|
||||
import React from 'react';
|
||||
import React, { ComponentProps } from 'react';
|
||||
import expect from 'app/test/unexpected';
|
||||
import sinon from 'sinon';
|
||||
import { shallow, mount } from 'enzyme';
|
||||
import { shallow, mount, ShallowWrapper, ReactWrapper } from 'enzyme';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import feedback from 'app/services/api/feedback';
|
||||
import { User } from 'app/components/user';
|
||||
|
||||
import { ContactForm } from './ContactForm';
|
||||
|
||||
type ContactFormShallowType = ShallowWrapper<
|
||||
ComponentProps<typeof ContactForm>,
|
||||
any,
|
||||
ContactForm
|
||||
>;
|
||||
|
||||
describe('ContactForm', () => {
|
||||
describe('when rendered', () => {
|
||||
const user = {} as User;
|
||||
let component;
|
||||
let component: ContactFormShallowType;
|
||||
|
||||
beforeEach(() => {
|
||||
component = shallow(<ContactForm user={user} />);
|
||||
@ -57,7 +63,7 @@ describe('ContactForm', () => {
|
||||
const user = {
|
||||
email: 'foo@bar.com',
|
||||
} as User;
|
||||
let component;
|
||||
let component: ContactFormShallowType;
|
||||
|
||||
beforeEach(() => {
|
||||
component = shallow(<ContactForm user={user} />);
|
||||
@ -76,7 +82,7 @@ describe('ContactForm', () => {
|
||||
const user = {
|
||||
email: 'foo@bar.com',
|
||||
} as User;
|
||||
let component;
|
||||
let component: ContactFormShallowType;
|
||||
|
||||
beforeEach(() => {
|
||||
component = shallow(<ContactForm user={user} />);
|
||||
@ -93,15 +99,15 @@ describe('ContactForm', () => {
|
||||
const user = {
|
||||
email: 'foo@bar.com',
|
||||
} as User;
|
||||
let component;
|
||||
let wrapper;
|
||||
let component: ContactForm;
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
// TODO: add polyfill for from validation for jsdom
|
||||
|
||||
wrapper = mount(
|
||||
<IntlProvider locale="en" defaultLocale="en">
|
||||
<ContactForm user={user} ref={el => (component = el)} />
|
||||
<ContactForm user={user} ref={el => (component = el!)} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
});
|
||||
@ -118,8 +124,8 @@ describe('ContactForm', () => {
|
||||
const user = {
|
||||
email: 'foo@bar.com',
|
||||
} as User;
|
||||
let component;
|
||||
let wrapper;
|
||||
let component: ContactForm;
|
||||
let wrapper: ReactWrapper;
|
||||
const requestData = {
|
||||
email: user.email,
|
||||
subject: 'Test subject',
|
||||
@ -137,16 +143,18 @@ describe('ContactForm', () => {
|
||||
// TODO: try to rewrite with unexpected-react
|
||||
wrapper = mount(
|
||||
<IntlProvider locale="en" defaultLocale="en">
|
||||
<ContactForm user={user} ref={el => (component = el)} />
|
||||
<ContactForm user={user} ref={el => (component = el!)} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
wrapper.find('input[name="email"]').getDOMNode().value =
|
||||
wrapper.find('input[name="email"]').getDOMNode<HTMLInputElement>().value =
|
||||
requestData.email;
|
||||
wrapper.find('input[name="subject"]').getDOMNode().value =
|
||||
requestData.subject;
|
||||
wrapper.find('textarea[name="message"]').getDOMNode().value =
|
||||
requestData.message;
|
||||
wrapper
|
||||
.find('input[name="subject"]')
|
||||
.getDOMNode<HTMLInputElement>().value = requestData.subject;
|
||||
wrapper
|
||||
.find('textarea[name="message"]')
|
||||
.getDOMNode<HTMLInputElement>().value = requestData.message;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -172,9 +172,9 @@ export class ContactForm extends React.Component<
|
||||
);
|
||||
}
|
||||
|
||||
onSubmit = () => {
|
||||
onSubmit = (): Promise<void> => {
|
||||
if (this.state.isLoading) {
|
||||
return;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
this.setState({ isLoading: true });
|
||||
|
@ -1,17 +1,15 @@
|
||||
import { Dispatch } from 'redux';
|
||||
import { Dispatch, Action as ReduxAction } from 'redux';
|
||||
import { OauthAppResponse } from 'app/services/api/oauth';
|
||||
import oauth from 'app/services/api/oauth';
|
||||
import { User } from 'app/components/user';
|
||||
import { ThunkAction } from 'app/reducers';
|
||||
|
||||
import { Apps } from './reducer';
|
||||
|
||||
type SetAvailableAction = {
|
||||
interface SetAvailableAction extends ReduxAction {
|
||||
type: 'apps:setAvailable';
|
||||
payload: Array<OauthAppResponse>;
|
||||
};
|
||||
type DeleteAppAction = { type: 'apps:deleteApp'; payload: string };
|
||||
type AddAppAction = { type: 'apps:addApp'; payload: OauthAppResponse };
|
||||
export type Action = SetAvailableAction | DeleteAppAction | AddAppAction;
|
||||
}
|
||||
|
||||
export function setAppsList(apps: Array<OauthAppResponse>): SetAvailableAction {
|
||||
return {
|
||||
@ -27,14 +25,19 @@ export function getApp(
|
||||
return state.apps.available.find(app => app.clientId === clientId) || null;
|
||||
}
|
||||
|
||||
export function fetchApp(clientId: string) {
|
||||
return async (dispatch: Dispatch<any>): Promise<void> => {
|
||||
export function fetchApp(clientId: string): ThunkAction<Promise<void>> {
|
||||
return async dispatch => {
|
||||
const app = await oauth.getApp(clientId);
|
||||
|
||||
dispatch(addApp(app));
|
||||
};
|
||||
}
|
||||
|
||||
interface AddAppAction extends ReduxAction {
|
||||
type: 'apps:addApp';
|
||||
payload: OauthAppResponse;
|
||||
}
|
||||
|
||||
function addApp(app: OauthAppResponse): AddAppAction {
|
||||
return {
|
||||
type: 'apps:addApp',
|
||||
@ -69,6 +72,11 @@ export function deleteApp(clientId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
interface DeleteAppAction extends ReduxAction {
|
||||
type: 'apps:deleteApp';
|
||||
payload: string;
|
||||
}
|
||||
|
||||
function createDeleteAppAction(clientId: string): DeleteAppAction {
|
||||
return {
|
||||
type: 'apps:deleteApp',
|
||||
@ -76,8 +84,11 @@ function createDeleteAppAction(clientId: string): DeleteAppAction {
|
||||
};
|
||||
}
|
||||
|
||||
export function resetApp(clientId: string, resetSecret: boolean) {
|
||||
return async (dispatch: Dispatch<any>): Promise<void> => {
|
||||
export function resetApp(
|
||||
clientId: string,
|
||||
resetSecret: boolean,
|
||||
): ThunkAction<Promise<void>> {
|
||||
return async dispatch => {
|
||||
const { data: app } = await oauth.reset(clientId, resetSecret);
|
||||
|
||||
if (resetSecret) {
|
||||
@ -85,3 +96,5 @@ export function resetApp(clientId: string, resetSecret: boolean) {
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type Action = SetAvailableAction | DeleteAppAction | AddAppAction;
|
||||
|
@ -19,12 +19,15 @@ import ApplicationTypeSwitcher from './ApplicationTypeSwitcher';
|
||||
import WebsiteType from './WebsiteType';
|
||||
import MinecraftServerType from './MinecraftServerType';
|
||||
|
||||
const typeToForm: {
|
||||
[K in ApplicationType]: {
|
||||
type TypeToForm = Record<
|
||||
ApplicationType,
|
||||
{
|
||||
label: MessageDescriptor;
|
||||
component: React.ComponentType<any>;
|
||||
};
|
||||
} = {
|
||||
}
|
||||
>;
|
||||
|
||||
const typeToForm: TypeToForm = {
|
||||
[TYPE_APPLICATION]: {
|
||||
label: messages.website,
|
||||
component: WebsiteType,
|
||||
@ -35,16 +38,15 @@ const typeToForm: {
|
||||
},
|
||||
};
|
||||
|
||||
const typeToLabel = Object.keys(typeToForm).reduce(
|
||||
(result, key: ApplicationType) => {
|
||||
result[key] = typeToForm[key].label;
|
||||
type TypeToLabel = Record<ApplicationType, MessageDescriptor>;
|
||||
|
||||
return result;
|
||||
},
|
||||
{} as {
|
||||
[K in ApplicationType]: MessageDescriptor;
|
||||
},
|
||||
);
|
||||
const typeToLabel: TypeToLabel = ((Object.keys(typeToForm) as unknown) as Array<
|
||||
ApplicationType
|
||||
>).reduce((result, key) => {
|
||||
result[key] = typeToForm[key].label;
|
||||
|
||||
return result;
|
||||
}, {} as TypeToLabel);
|
||||
|
||||
export default class ApplicationForm extends React.Component<{
|
||||
app: OauthAppResponse;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { ApplicationType } from 'app/components/dev/apps';
|
||||
import { MessageDescriptor } from 'react-intl';
|
||||
import { SKIN_LIGHT } from 'app/components/ui';
|
||||
@ -6,20 +6,20 @@ import { Radio } from 'app/components/ui/form';
|
||||
|
||||
import styles from './applicationTypeSwitcher.scss';
|
||||
|
||||
export default function ApplicationTypeSwitcher({
|
||||
setType,
|
||||
appTypes,
|
||||
selectedType,
|
||||
}: {
|
||||
appTypes: {
|
||||
[K in ApplicationType]: MessageDescriptor;
|
||||
};
|
||||
interface Props {
|
||||
appTypes: Record<ApplicationType, MessageDescriptor>;
|
||||
selectedType: ApplicationType | null;
|
||||
setType: (type: ApplicationType) => void;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{Object.keys(appTypes).map((type: ApplicationType) => (
|
||||
}
|
||||
|
||||
const ApplicationTypeSwitcher: ComponentType<Props> = ({
|
||||
appTypes,
|
||||
selectedType,
|
||||
setType,
|
||||
}) => (
|
||||
<div>
|
||||
{((Object.keys(appTypes) as unknown) as Array<ApplicationType>).map(
|
||||
type => (
|
||||
<div className={styles.radioContainer} key={type}>
|
||||
<Radio
|
||||
onChange={() => setType(type)}
|
||||
@ -29,7 +29,9 @@ export default function ApplicationTypeSwitcher({
|
||||
checked={selectedType === type}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ApplicationTypeSwitcher;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { OauthAppResponse } from 'app/services/api/oauth';
|
||||
import { Input, FormModel } from 'app/components/ui/form';
|
||||
@ -7,52 +7,51 @@ import styles from 'app/components/profile/profileForm.scss';
|
||||
|
||||
import messages from './ApplicationForm.intl.json';
|
||||
|
||||
export default function MinecraftServerType({
|
||||
form,
|
||||
app,
|
||||
}: {
|
||||
interface Props {
|
||||
form: FormModel;
|
||||
app: OauthAppResponse;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...form.bindField('name')}
|
||||
label={messages.serverName}
|
||||
defaultValue={app.name}
|
||||
required
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.ipAddressIsOptionButPreferable} />
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...form.bindField('minecraftServerIp')}
|
||||
label={messages.serverIp}
|
||||
defaultValue={app.minecraftServerIp}
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.youCanAlsoSpecifyServerSite} />
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...form.bindField('websiteUrl')}
|
||||
label={messages.websiteLink}
|
||||
defaultValue={app.websiteUrl}
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MinecraftServerType: ComponentType<Props> = ({ form, app }) => (
|
||||
<div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...form.bindField('name')}
|
||||
label={messages.serverName}
|
||||
defaultValue={app.name}
|
||||
required
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.ipAddressIsOptionButPreferable} />
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...form.bindField('minecraftServerIp')}
|
||||
label={messages.serverIp}
|
||||
defaultValue={app.minecraftServerIp}
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.youCanAlsoSpecifyServerSite} />
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...form.bindField('websiteUrl')}
|
||||
label={messages.websiteLink}
|
||||
defaultValue={app.websiteUrl}
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MinecraftServerType;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { Input, TextArea, FormModel } from 'app/components/ui/form';
|
||||
import { OauthAppResponse } from 'app/services/api/oauth';
|
||||
@ -7,68 +7,67 @@ import styles from 'app/components/profile/profileForm.scss';
|
||||
|
||||
import messages from './ApplicationForm.intl.json';
|
||||
|
||||
export default function WebsiteType({
|
||||
form,
|
||||
app,
|
||||
}: {
|
||||
interface Props {
|
||||
form: FormModel;
|
||||
app: OauthAppResponse;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...form.bindField('name')}
|
||||
label={messages.applicationName}
|
||||
defaultValue={app.name}
|
||||
required
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.appDescriptionWillBeAlsoVisibleOnOauthPage} />
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<TextArea
|
||||
{...form.bindField('description')}
|
||||
label={messages.description}
|
||||
defaultValue={app.description}
|
||||
skin={SKIN_LIGHT}
|
||||
minRows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.websiteLinkWillBeUsedAsAdditionalId} />
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...form.bindField('websiteUrl')}
|
||||
label={messages.websiteLink}
|
||||
defaultValue={app.websiteUrl}
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.redirectUriLimitsAllowableBaseAddress} />
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...form.bindField('redirectUri')}
|
||||
label={messages.redirectUri}
|
||||
defaultValue={app.redirectUri}
|
||||
required
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const WebsiteType: ComponentType<Props> = ({ form, app }) => (
|
||||
<div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...form.bindField('name')}
|
||||
label={messages.applicationName}
|
||||
defaultValue={app.name}
|
||||
required
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.appDescriptionWillBeAlsoVisibleOnOauthPage} />
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<TextArea
|
||||
{...form.bindField('description')}
|
||||
label={messages.description}
|
||||
defaultValue={app.description}
|
||||
skin={SKIN_LIGHT}
|
||||
minRows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.websiteLinkWillBeUsedAsAdditionalId} />
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...form.bindField('websiteUrl')}
|
||||
label={messages.websiteLink}
|
||||
defaultValue={app.websiteUrl}
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.redirectUriLimitsAllowableBaseAddress} />
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...form.bindField('redirectUri')}
|
||||
label={messages.redirectUri}
|
||||
defaultValue={app.redirectUri}
|
||||
required
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default WebsiteType;
|
||||
|
@ -3,7 +3,7 @@ import { OauthAppResponse } from 'app/services/api/oauth';
|
||||
import { Action } from './actions';
|
||||
|
||||
export interface Apps {
|
||||
available: OauthAppResponse[];
|
||||
available: Array<OauthAppResponse>;
|
||||
}
|
||||
|
||||
const defaults: Apps = {
|
||||
@ -42,8 +42,6 @@ export default function apps(state: Apps = defaults, action: Action): Apps {
|
||||
app => app.clientId !== action.payload,
|
||||
),
|
||||
};
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
return state;
|
||||
|
@ -1,16 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import React, { useState, useEffect, ComponentType } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { RawIntlProvider, IntlShape } from 'react-intl';
|
||||
import i18n from 'app/services/i18n';
|
||||
import { RootState } from 'app/reducers';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
function IntlProvider({ children, locale }: Props) {
|
||||
const IntlProvider: ComponentType = ({ children }) => {
|
||||
const [intl, setIntl] = useState<IntlShape>(i18n.getIntl());
|
||||
const locale = useSelector(
|
||||
({ i18n: i18nState }: RootState) => i18nState.locale,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@ -19,8 +17,6 @@ function IntlProvider({ children, locale }: Props) {
|
||||
}, [locale]);
|
||||
|
||||
return <RawIntlProvider value={intl}>{children}</RawIntlProvider>;
|
||||
}
|
||||
};
|
||||
|
||||
export default connect(({ i18n: i18nState }: RootState) => i18nState)(
|
||||
IntlProvider,
|
||||
);
|
||||
export default IntlProvider;
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { Action as ReduxAction } from 'redux';
|
||||
import i18n from 'app/services/i18n';
|
||||
import { ThunkAction } from 'app/reducers';
|
||||
|
||||
export const SET_LOCALE = 'i18n:setLocale';
|
||||
export function setLocale(desiredLocale: string) {
|
||||
return async (
|
||||
dispatch: (action: { [key: string]: any }) => any,
|
||||
): Promise<string> => {
|
||||
export function setLocale(desiredLocale: string): ThunkAction<Promise<string>> {
|
||||
return async dispatch => {
|
||||
const locale = i18n.detectLanguage(desiredLocale);
|
||||
|
||||
dispatch(_setLocale(locale));
|
||||
@ -13,11 +12,20 @@ export function setLocale(desiredLocale: string) {
|
||||
};
|
||||
}
|
||||
|
||||
function _setLocale(locale: string) {
|
||||
interface SetAction extends ReduxAction {
|
||||
type: 'i18n:setLocale';
|
||||
payload: {
|
||||
locale: string;
|
||||
};
|
||||
}
|
||||
|
||||
function _setLocale(locale: string): SetAction {
|
||||
return {
|
||||
type: SET_LOCALE,
|
||||
type: 'i18n:setLocale',
|
||||
payload: {
|
||||
locale,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type Action = SetAction;
|
||||
|
@ -1,6 +1,6 @@
|
||||
import supportedLocales from 'app/i18n';
|
||||
|
||||
const localeToCountryCode = {
|
||||
const localeToCountryCode: Record<string, string> = {
|
||||
en: 'gb',
|
||||
be: 'by',
|
||||
pt: 'br',
|
||||
|
@ -1,18 +1,20 @@
|
||||
import i18n from 'app/services/i18n';
|
||||
|
||||
import { SET_LOCALE } from './actions';
|
||||
import { Action } from './actions';
|
||||
|
||||
export type State = { locale: string };
|
||||
export interface State {
|
||||
locale: string;
|
||||
}
|
||||
|
||||
const defaultState = {
|
||||
const defaultState: State = {
|
||||
locale: i18n.detectLanguage(),
|
||||
};
|
||||
|
||||
export default function(
|
||||
state: State = defaultState,
|
||||
{ type, payload }: { type: string; payload: State },
|
||||
{ type, payload }: Action,
|
||||
): State {
|
||||
if (type === SET_LOCALE) {
|
||||
if (type === 'i18n:setLocale') {
|
||||
return payload;
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,13 @@
|
||||
import React from 'react';
|
||||
import { TransitionMotion, spring, presets } from 'react-motion';
|
||||
import React, { MouseEventHandler } from 'react';
|
||||
import {
|
||||
TransitionMotion,
|
||||
spring,
|
||||
presets,
|
||||
TransitionStyle,
|
||||
TransitionPlainStyle,
|
||||
PlainStyle,
|
||||
Style,
|
||||
} from 'react-motion';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import clsx from 'clsx';
|
||||
|
||||
@ -13,6 +21,34 @@ import mayTheForceBeWithYou from './images/may_the_force_be_with_you.svg';
|
||||
import biteMyShinyMetalAss from './images/bite_my_shiny_metal_ass.svg';
|
||||
import iTookAnArrowInMyKnee from './images/i_took_an_arrow_in_my_knee.svg';
|
||||
|
||||
interface EmptyCaption {
|
||||
src: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
const emptyCaptions: ReadonlyArray<EmptyCaption> = [
|
||||
{
|
||||
// Homestuck
|
||||
src: thatFuckingPumpkin,
|
||||
caption: 'That fucking pumpkin',
|
||||
},
|
||||
{
|
||||
// Star Wars
|
||||
src: mayTheForceBeWithYou,
|
||||
caption: 'May The Force Be With You',
|
||||
},
|
||||
{
|
||||
// Futurama
|
||||
src: biteMyShinyMetalAss,
|
||||
caption: 'Bite my shiny metal ass',
|
||||
},
|
||||
{
|
||||
// The Elder Scrolls V: Skyrim
|
||||
src: iTookAnArrowInMyKnee,
|
||||
caption: 'I took an arrow in my knee',
|
||||
},
|
||||
];
|
||||
|
||||
const itemHeight = 51;
|
||||
|
||||
export default class LanguageList extends React.Component<{
|
||||
@ -84,70 +120,60 @@ export default class LanguageList extends React.Component<{
|
||||
);
|
||||
}
|
||||
|
||||
getEmptyCaption() {
|
||||
const emptyCaptions = [
|
||||
{
|
||||
// Homestuck
|
||||
src: thatFuckingPumpkin,
|
||||
caption: 'That fucking pumpkin',
|
||||
},
|
||||
{
|
||||
// Star Wars
|
||||
src: mayTheForceBeWithYou,
|
||||
caption: 'May The Force Be With You',
|
||||
},
|
||||
{
|
||||
// Futurama
|
||||
src: biteMyShinyMetalAss,
|
||||
caption: 'Bite my shiny metal ass',
|
||||
},
|
||||
{
|
||||
// The Elder Scrolls V: Skyrim
|
||||
src: iTookAnArrowInMyKnee,
|
||||
caption: 'I took an arrow in my knee',
|
||||
},
|
||||
];
|
||||
|
||||
getEmptyCaption(): EmptyCaption {
|
||||
return emptyCaptions[Math.floor(Math.random() * emptyCaptions.length)];
|
||||
}
|
||||
|
||||
onChangeLang(lang: string) {
|
||||
return (event: React.MouseEvent<HTMLElement>) => {
|
||||
onChangeLang(lang: string): MouseEventHandler {
|
||||
return event => {
|
||||
event.preventDefault();
|
||||
|
||||
this.props.onChangeLang(lang);
|
||||
};
|
||||
}
|
||||
|
||||
getItemsWithDefaultStyles = () =>
|
||||
this.getItemsWithStyles({ useSpring: false });
|
||||
|
||||
getItemsWithStyles = (
|
||||
{ useSpring }: { useSpring?: boolean } = { useSpring: true },
|
||||
) =>
|
||||
Object.keys({ ...this.props.langs }).reduce(
|
||||
getItemsWithDefaultStyles = (): Array<TransitionPlainStyle> => {
|
||||
return Object.keys({ ...this.props.langs }).reduce(
|
||||
(previous, key) => [
|
||||
...previous,
|
||||
{
|
||||
key,
|
||||
data: this.props.langs[key],
|
||||
style: {
|
||||
height: useSpring ? spring(itemHeight, presets.gentle) : itemHeight,
|
||||
opacity: useSpring ? spring(1, presets.gentle) : 1,
|
||||
height: itemHeight,
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[] as Array<TransitionPlainStyle>,
|
||||
);
|
||||
};
|
||||
|
||||
willEnter() {
|
||||
getItemsWithStyles = (): Array<TransitionStyle> => {
|
||||
return Object.keys({ ...this.props.langs }).reduce(
|
||||
(previous, key) => [
|
||||
...previous,
|
||||
{
|
||||
key,
|
||||
data: this.props.langs[key],
|
||||
style: {
|
||||
height: spring(itemHeight, presets.gentle),
|
||||
opacity: spring(1, presets.gentle),
|
||||
},
|
||||
},
|
||||
],
|
||||
[] as Array<TransitionStyle>,
|
||||
);
|
||||
};
|
||||
|
||||
willEnter(): PlainStyle {
|
||||
return {
|
||||
height: 0,
|
||||
opacity: 1,
|
||||
};
|
||||
}
|
||||
|
||||
willLeave() {
|
||||
willLeave(): Style {
|
||||
return {
|
||||
height: spring(0),
|
||||
opacity: spring(0),
|
||||
|
@ -15,15 +15,15 @@ import { RootState } from 'app/reducers';
|
||||
|
||||
const translateUrl = 'http://ely.by/translate';
|
||||
|
||||
export type LocaleData = {
|
||||
export interface LocaleData {
|
||||
code: string;
|
||||
name: string;
|
||||
englishName: string;
|
||||
progress: number;
|
||||
isReleased: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export type LocalesMap = { [code: string]: LocaleData };
|
||||
export type LocalesMap = Record<string, LocaleData>;
|
||||
|
||||
type OwnProps = {
|
||||
onClose: () => void;
|
||||
@ -142,7 +142,7 @@ class LanguageSwitcher extends React.Component<
|
||||
previous[key] = langs[key];
|
||||
|
||||
return previous;
|
||||
}, {});
|
||||
}, {} as typeof langs);
|
||||
|
||||
this.setState({
|
||||
filter,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { ComponentType, ReactNode } from 'react';
|
||||
import { localeFlags } from 'app/components/i18n';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
|
||||
@ -6,10 +6,14 @@ import messages from './languageSwitcher.intl.json';
|
||||
import styles from './languageSwitcher.scss';
|
||||
import { LocaleData } from './LanguageSwitcher';
|
||||
|
||||
export default function LocaleItem({ locale }: { locale: LocaleData }) {
|
||||
const { code, name, englishName, progress, isReleased } = locale;
|
||||
interface Props {
|
||||
locale: LocaleData;
|
||||
}
|
||||
|
||||
let progressLabel;
|
||||
const LocaleItem: ComponentType<Props> = ({
|
||||
locale: { code, name, englishName, progress, isReleased },
|
||||
}) => {
|
||||
let progressLabel: ReactNode;
|
||||
|
||||
if (progress !== 100) {
|
||||
progressLabel = (
|
||||
@ -41,4 +45,6 @@ export default function LocaleItem({ locale }: { locale: LocaleData }) {
|
||||
<span className={styles.languageCircle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default LocaleItem;
|
||||
|
@ -1,25 +1,23 @@
|
||||
import React from 'react';
|
||||
import React, { ComponentType, useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import clsx from 'clsx';
|
||||
import { localeFlags } from 'app/components/i18n';
|
||||
import LANGS from 'app/i18n';
|
||||
import { connect } from 'react-redux';
|
||||
import { create as createPopup } from 'app/components/ui/popup/actions';
|
||||
import LanguageSwitcher from 'app/components/languageSwitcher';
|
||||
import { RootState } from 'app/reducers';
|
||||
|
||||
import styles from './link.scss';
|
||||
|
||||
type Props = {
|
||||
userLang: string;
|
||||
interfaceLocale: string;
|
||||
showLanguageSwitcherPopup: (event: React.MouseEvent<HTMLSpanElement>) => void;
|
||||
};
|
||||
const LanguageLink: ComponentType = () => {
|
||||
const dispatch = useDispatch();
|
||||
const showLanguageSwitcherPopup = useCallback(() => {
|
||||
dispatch(createPopup({ Popup: LanguageSwitcher }));
|
||||
}, [dispatch]);
|
||||
|
||||
const userLang = useSelector((state: RootState) => state.user.lang);
|
||||
const interfaceLocale = useSelector((state: RootState) => state.i18n.locale);
|
||||
|
||||
function LanguageLink({
|
||||
userLang,
|
||||
interfaceLocale,
|
||||
showLanguageSwitcherPopup,
|
||||
}: Props) {
|
||||
const localeDefinition = LANGS[userLang] || LANGS[interfaceLocale];
|
||||
|
||||
return (
|
||||
@ -40,14 +38,6 @@ function LanguageLink({
|
||||
{localeDefinition.name}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default connect(
|
||||
(state: RootState) => ({
|
||||
userLang: state.user.lang,
|
||||
interfaceLocale: state.i18n.locale,
|
||||
}),
|
||||
{
|
||||
showLanguageSwitcherPopup: () => createPopup({ Popup: LanguageSwitcher }),
|
||||
},
|
||||
)(LanguageLink);
|
||||
export default LanguageLink;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import { SlideMotion } from 'app/components/ui/motion';
|
||||
@ -21,27 +21,31 @@ import messages from './ChangeEmail.intl.json';
|
||||
const STEPS_TOTAL = 3;
|
||||
|
||||
export type ChangeEmailStep = 0 | 1 | 2;
|
||||
type HeightProp = 'step0Height' | 'step1Height' | 'step2Height';
|
||||
type HeightDict = {
|
||||
[K in HeightProp]?: number;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
onChangeStep: (step: ChangeEmailStep) => void;
|
||||
lang: string;
|
||||
email: string;
|
||||
stepForms: FormModel[];
|
||||
stepForms: Array<FormModel>;
|
||||
onSubmit: (step: ChangeEmailStep, form: FormModel) => Promise<void>;
|
||||
step: ChangeEmailStep;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
interface State extends HeightDict {
|
||||
interface State {
|
||||
newEmail: string | null;
|
||||
activeStep: ChangeEmailStep;
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface FormStepParams {
|
||||
form: FormModel;
|
||||
isActiveStep: boolean;
|
||||
isCodeSpecified: boolean;
|
||||
email: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export default class ChangeEmail extends React.Component<Props, State> {
|
||||
static get defaultProps(): Partial<Props> {
|
||||
return {
|
||||
@ -145,21 +149,28 @@ export default class ChangeEmail extends React.Component<Props, State> {
|
||||
return (
|
||||
<SlideMotion activeStep={activeStep}>
|
||||
{new Array(STEPS_TOTAL).fill(0).map((_, step) => {
|
||||
const form = this.props.stepForms[step];
|
||||
|
||||
return this[`renderStep${step}`]({
|
||||
const formParams: FormStepParams = {
|
||||
form: this.props.stepForms[step],
|
||||
isActiveStep: step === activeStep,
|
||||
isCodeSpecified,
|
||||
email,
|
||||
code,
|
||||
isCodeSpecified,
|
||||
form,
|
||||
isActiveStep: step === activeStep,
|
||||
});
|
||||
};
|
||||
|
||||
switch (step) {
|
||||
case 0:
|
||||
return this.renderStep0(formParams);
|
||||
case 1:
|
||||
return this.renderStep1(formParams);
|
||||
case 2:
|
||||
return this.renderStep2(formParams);
|
||||
}
|
||||
})}
|
||||
</SlideMotion>
|
||||
);
|
||||
}
|
||||
|
||||
renderStep0({ email, form }) {
|
||||
renderStep0({ email, form }: FormStepParams): ReactNode {
|
||||
return (
|
||||
<div key="step0" data-testid="step1" className={styles.formBody}>
|
||||
<div className={styles.formRow}>
|
||||
@ -183,7 +194,13 @@ export default class ChangeEmail extends React.Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
renderStep1({ email, form, code, isCodeSpecified, isActiveStep }) {
|
||||
renderStep1({
|
||||
email,
|
||||
form,
|
||||
code,
|
||||
isCodeSpecified,
|
||||
isActiveStep,
|
||||
}: FormStepParams): ReactNode {
|
||||
return (
|
||||
<div key="step1" data-testid="step2" className={styles.formBody}>
|
||||
<div className={styles.formRow}>
|
||||
@ -230,7 +247,12 @@ export default class ChangeEmail extends React.Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
renderStep2({ form, code, isCodeSpecified, isActiveStep }) {
|
||||
renderStep2({
|
||||
form,
|
||||
code,
|
||||
isCodeSpecified,
|
||||
isActiveStep,
|
||||
}: FormStepParams): ReactNode {
|
||||
const { newEmail } = this.state;
|
||||
|
||||
return (
|
||||
@ -268,14 +290,6 @@ export default class ChangeEmail extends React.Component<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
onStepMeasure(step: ChangeEmailStep) {
|
||||
return (height: number) =>
|
||||
// @ts-ignore
|
||||
this.setState({
|
||||
[`step${step}Height`]: height,
|
||||
});
|
||||
}
|
||||
|
||||
nextStep() {
|
||||
const { activeStep } = this.state;
|
||||
const nextStep = activeStep + 1;
|
||||
|
@ -15,6 +15,7 @@ describe('<ChangePassword />', () => {
|
||||
|
||||
it('should call onSubmit if passwords entered', () => {
|
||||
const onSubmit = sinon.spy(() => ({ catch: () => {} })).named('onSubmit');
|
||||
// @ts-ignore
|
||||
const component = shallow(<ChangePassword onSubmit={onSubmit} />);
|
||||
|
||||
component.find('Form').simulate('submit');
|
@ -55,7 +55,7 @@ export default class MfaEnable extends React.PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props: Props, state: State) {
|
||||
if (typeof props.step === 'number' && props.step !== state.activeStep) {
|
||||
if (props.step !== state.activeStep) {
|
||||
return {
|
||||
activeStep: props.step,
|
||||
};
|
||||
@ -122,7 +122,7 @@ export default class MfaEnable extends React.PureComponent<Props, State> {
|
||||
<Confirmation
|
||||
key="step3"
|
||||
form={this.props.confirmationForm}
|
||||
formRef={(el: Form) => (this.confirmationFormEl = el)}
|
||||
formRef={el => (this.confirmationFormEl = el)}
|
||||
onSubmit={this.onTotpSubmit}
|
||||
onInvalid={() => this.forceUpdate()}
|
||||
/>
|
||||
|
@ -1,8 +1,9 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { Input, Form, FormModel } from 'app/components/ui/form';
|
||||
|
||||
import { Input, Form, FormModel } from 'app/components/ui/form';
|
||||
import profileForm from 'app/components/profile/profileForm.scss';
|
||||
|
||||
import messages from '../MultiFactorAuth.intl.json';
|
||||
|
||||
export default function Confirmation({
|
||||
@ -13,7 +14,7 @@ export default function Confirmation({
|
||||
}: {
|
||||
form: FormModel;
|
||||
formRef?: (el: Form | null) => void;
|
||||
onSubmit: (form: FormModel) => Promise<void>;
|
||||
onSubmit: (form: FormModel) => Promise<void> | void;
|
||||
onInvalid: () => void;
|
||||
}) {
|
||||
return (
|
||||
|
@ -1,54 +1,52 @@
|
||||
import React from 'react';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { Form, Button, Input, FormModel } from 'app/components/ui/form';
|
||||
import popupStyles from 'app/components/ui/popup/popup.scss';
|
||||
|
||||
import styles from './passwordRequestForm.scss';
|
||||
import messages from './PasswordRequestForm.intl.json';
|
||||
|
||||
function PasswordRequestForm({
|
||||
form,
|
||||
onSubmit,
|
||||
}: {
|
||||
interface Props {
|
||||
form: FormModel;
|
||||
onSubmit: (form: FormModel) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={styles.requestPasswordForm}
|
||||
data-testid="password-request-form"
|
||||
>
|
||||
<div className={popupStyles.popup}>
|
||||
<Form onSubmit={() => onSubmit(form)} form={form}>
|
||||
<div className={popupStyles.header}>
|
||||
<h2 className={popupStyles.headerTitle}>
|
||||
<Message {...messages.title} />
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className={clsx(popupStyles.body, styles.body)}>
|
||||
<span className={styles.lockIcon} />
|
||||
|
||||
<div className={styles.description}>
|
||||
<Message {...messages.description} />
|
||||
</div>
|
||||
|
||||
<Input
|
||||
{...form.bindField('password')}
|
||||
type="password"
|
||||
required
|
||||
autoFocus
|
||||
color="green"
|
||||
skin="light"
|
||||
center
|
||||
/>
|
||||
</div>
|
||||
<Button color="green" label={messages.continue} block type="submit" />
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PasswordRequestForm: ComponentType<Props> = ({ form, onSubmit }) => (
|
||||
<div
|
||||
className={styles.requestPasswordForm}
|
||||
data-testid="password-request-form"
|
||||
>
|
||||
<div className={popupStyles.popup}>
|
||||
<Form onSubmit={onSubmit} form={form}>
|
||||
<div className={popupStyles.header}>
|
||||
<h2 className={popupStyles.headerTitle}>
|
||||
<Message {...messages.title} />
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className={clsx(popupStyles.body, styles.body)}>
|
||||
<span className={styles.lockIcon} />
|
||||
|
||||
<div className={styles.description}>
|
||||
<Message {...messages.description} />
|
||||
</div>
|
||||
|
||||
<Input
|
||||
{...form.bindField('password')}
|
||||
type="password"
|
||||
required
|
||||
autoFocus
|
||||
color="green"
|
||||
skin="light"
|
||||
center
|
||||
/>
|
||||
</div>
|
||||
<Button color="green" label={messages.continue} block type="submit" />
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PasswordRequestForm;
|
||||
|
@ -91,9 +91,7 @@ export default class Box {
|
||||
endY: number;
|
||||
}> = [];
|
||||
|
||||
// eslint-disable-next-line guard-for-in
|
||||
for (const i in boxPoints) {
|
||||
const point = boxPoints[i];
|
||||
Object.values(boxPoints).forEach(point => {
|
||||
const angle = Math.atan2(light.y - point.y, light.x - point.x);
|
||||
const endX = point.x + shadowLength * Math.sin(-angle - Math.PI / 2);
|
||||
const endY = point.y + shadowLength * Math.cos(-angle - Math.PI / 2);
|
||||
@ -103,7 +101,7 @@ export default class Box {
|
||||
endX,
|
||||
endY,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
for (let i = points.length - 1; i >= 0; i--) {
|
||||
const n = i === 3 ? 0 : i + 1;
|
||||
|
@ -18,8 +18,7 @@ class BsodMiddleware implements Middleware {
|
||||
async catch<T extends Resp<any>>(
|
||||
resp?: T | InternalServerError | Error,
|
||||
): Promise<T> {
|
||||
const { originalResponse }: { originalResponse?: Resp<any> } = (resp ||
|
||||
{}) as InternalServerError;
|
||||
const { originalResponse } = (resp || {}) as InternalServerError;
|
||||
|
||||
if (
|
||||
resp &&
|
||||
|
@ -1,9 +1,13 @@
|
||||
import { Action } from 'redux';
|
||||
import { Action as ReduxAction } from 'redux';
|
||||
|
||||
export const BSOD = 'BSOD';
|
||||
interface BSoDAction extends ReduxAction {
|
||||
type: 'BSOD';
|
||||
}
|
||||
|
||||
export function bsod(): Action {
|
||||
export function bsod(): BSoDAction {
|
||||
return {
|
||||
type: BSOD,
|
||||
type: 'BSOD',
|
||||
};
|
||||
}
|
||||
|
||||
export type Action = BSoDAction;
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { BSOD } from './actions';
|
||||
import { Action } from './actions';
|
||||
|
||||
export type State = boolean;
|
||||
|
||||
export default function(state: State = false, { type }): State {
|
||||
if (type === BSOD) {
|
||||
export default function(state: State = false, { type }: Action): State {
|
||||
if (type === 'BSOD') {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { InputHTMLAttributes } from 'react';
|
||||
import React, { InputHTMLAttributes, MouseEventHandler } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { MessageDescriptor } from 'react-intl';
|
||||
import clsx from 'clsx';
|
||||
@ -40,10 +40,12 @@ export default class Dropdown extends FormInputComponent<Props, State> {
|
||||
componentDidMount() {
|
||||
// listen to capturing phase to ensure, that our event handler will be
|
||||
// called before all other
|
||||
// @ts-ignore
|
||||
document.addEventListener('click', this.onBodyClick, true);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
// @ts-ignore
|
||||
document.removeEventListener('click', this.onBodyClick);
|
||||
}
|
||||
|
||||
@ -98,7 +100,7 @@ export default class Dropdown extends FormInputComponent<Props, State> {
|
||||
});
|
||||
}
|
||||
|
||||
onSelectItem(item: OptionItem) {
|
||||
onSelectItem(item: OptionItem): MouseEventHandler {
|
||||
return event => {
|
||||
event.preventDefault();
|
||||
|
||||
@ -141,11 +143,12 @@ export default class Dropdown extends FormInputComponent<Props, State> {
|
||||
this.toggle();
|
||||
};
|
||||
|
||||
onBodyClick = (event: MouseEvent) => {
|
||||
onBodyClick: MouseEventHandler = event => {
|
||||
if (this.state.isActive) {
|
||||
const el = ReactDOM.findDOMNode(this);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const el = ReactDOM.findDOMNode(this)!;
|
||||
|
||||
if (!el.contains(event.target) && el !== event.target) {
|
||||
if (!el.contains(event.target as HTMLElement) && el !== event.target) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
|
@ -1,18 +1,33 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import logger from 'app/services/logger';
|
||||
|
||||
import FormModel from './FormModel';
|
||||
import styles from './form.scss';
|
||||
|
||||
interface Props {
|
||||
interface BaseProps {
|
||||
id: string;
|
||||
isLoading: boolean;
|
||||
form?: FormModel;
|
||||
onSubmit: (form: FormModel | FormData) => void | Promise<void>;
|
||||
onInvalid: (errors: { [errorKey: string]: string }) => void;
|
||||
onInvalid: (errors: Record<string, string>) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface PropsWithoutForm extends BaseProps {
|
||||
onSubmit: (form: FormData) => Promise<void> | void;
|
||||
}
|
||||
|
||||
interface PropsWithForm extends BaseProps {
|
||||
form: FormModel;
|
||||
onSubmit: (form: FormModel) => Promise<void> | void;
|
||||
}
|
||||
|
||||
type Props = PropsWithoutForm | PropsWithForm;
|
||||
|
||||
function hasForm(props: Props): props is PropsWithForm {
|
||||
return 'form' in props;
|
||||
}
|
||||
|
||||
interface State {
|
||||
id: string; // just to track value for derived updates
|
||||
isTouched: boolean;
|
||||
@ -39,7 +54,7 @@ export default class Form extends React.Component<Props, State> {
|
||||
mounted = false;
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.form) {
|
||||
if (hasForm(this.props)) {
|
||||
this.props.form.addLoadingListener(this.onLoading);
|
||||
}
|
||||
|
||||
@ -65,8 +80,8 @@ export default class Form extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
const nextForm = this.props.form;
|
||||
const prevForm = prevProps.form;
|
||||
const nextForm = hasForm(this.props) ? this.props.form : undefined;
|
||||
const prevForm = hasForm(prevProps) ? prevProps.form : undefined;
|
||||
|
||||
if (nextForm !== prevForm) {
|
||||
if (prevForm) {
|
||||
@ -80,7 +95,7 @@ export default class Form extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.props.form) {
|
||||
if (hasForm(this.props)) {
|
||||
this.props.form.removeLoadingListener(this.onLoading);
|
||||
}
|
||||
|
||||
@ -119,15 +134,19 @@ export default class Form extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
if (form.checkValidity()) {
|
||||
const result = this.props.onSubmit(
|
||||
this.props.form ? this.props.form : new FormData(form),
|
||||
);
|
||||
let result: Promise<void> | void;
|
||||
|
||||
if (hasForm(this.props)) {
|
||||
result = this.props.onSubmit(this.props.form);
|
||||
} else {
|
||||
result = this.props.onSubmit(new FormData(form));
|
||||
}
|
||||
|
||||
if (result && result.then) {
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
result
|
||||
.catch((errors: { [key: string]: string }) => {
|
||||
.catch((errors: Record<string, string>) => {
|
||||
this.setErrors(errors);
|
||||
})
|
||||
.finally(() => this.mounted && this.setState({ isLoading: false }));
|
||||
@ -136,10 +155,10 @@ export default class Form extends React.Component<Props, State> {
|
||||
const invalidEls: NodeListOf<InputElement> = form.querySelectorAll(
|
||||
':invalid',
|
||||
);
|
||||
const errors = {};
|
||||
const errors: Record<string, string> = {};
|
||||
invalidEls[0].focus(); // focus on first error
|
||||
|
||||
Array.from(invalidEls).reduce((acc, el: InputElement) => {
|
||||
Array.from(invalidEls).reduce((acc, el) => {
|
||||
if (!el.name) {
|
||||
logger.warn('Found an element without name', { el });
|
||||
|
||||
@ -164,7 +183,10 @@ export default class Form extends React.Component<Props, State> {
|
||||
}
|
||||
|
||||
setErrors(errors: { [key: string]: string }) {
|
||||
this.props.form && this.props.form.setErrors(errors);
|
||||
if (hasForm(this.props)) {
|
||||
this.props.form.setErrors(errors);
|
||||
}
|
||||
|
||||
this.props.onInvalid(errors);
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { MessageDescriptor } from 'react-intl';
|
||||
import i18n from 'app/services/i18n';
|
||||
|
||||
class FormComponent<P, S = {}> extends React.Component<P, S> {
|
||||
export default class FormComponent<P, S = {}> extends React.Component<P, S> {
|
||||
/**
|
||||
* Formats message resolving intl translations
|
||||
*
|
||||
@ -37,5 +37,3 @@ class FormComponent<P, S = {}> extends React.Component<P, S> {
|
||||
*/
|
||||
onFormInvalid() {}
|
||||
}
|
||||
|
||||
export default FormComponent;
|
||||
|
@ -1,31 +1,36 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import errorsDict from 'app/services/errorsDict';
|
||||
import React, { ComponentType, ReactNode } from 'react';
|
||||
import { resolve as resolveError } from 'app/services/errorsDict';
|
||||
import { MessageDescriptor } from 'react-intl';
|
||||
|
||||
import styles from './form.scss';
|
||||
|
||||
export default function FormError({
|
||||
error,
|
||||
}: {
|
||||
error?:
|
||||
| string
|
||||
| React.ReactNode
|
||||
| {
|
||||
type: string;
|
||||
payload: { [key: string]: any };
|
||||
};
|
||||
}) {
|
||||
return error ? (
|
||||
<div className={styles.fieldError}>{errorsDict.resolve(error)}</div>
|
||||
) : null;
|
||||
interface Props {
|
||||
error?: Parameters<typeof resolveError>[0] | MessageDescriptor | null;
|
||||
}
|
||||
|
||||
FormError.propTypes = {
|
||||
error: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.shape({
|
||||
type: PropTypes.string.isRequired,
|
||||
payload: PropTypes.object.isRequired,
|
||||
}),
|
||||
]),
|
||||
function isMessageDescriptor(
|
||||
message: Props['error'],
|
||||
): message is MessageDescriptor {
|
||||
return (
|
||||
typeof message === 'object' &&
|
||||
typeof (message as MessageDescriptor).id !== 'undefined'
|
||||
);
|
||||
}
|
||||
|
||||
const FormError: ComponentType<Props> = ({ error }) => {
|
||||
if (!error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let content: ReactNode;
|
||||
|
||||
if (isMessageDescriptor(error)) {
|
||||
content = error;
|
||||
} else {
|
||||
content = resolveError(error);
|
||||
}
|
||||
|
||||
return <div className={styles.fieldError}>{content}</div>;
|
||||
};
|
||||
|
||||
export default FormError;
|
||||
|
@ -6,15 +6,13 @@ export type ValidationError =
|
||||
| string
|
||||
| {
|
||||
type: string;
|
||||
payload?: { [key: string]: any };
|
||||
payload?: Record<string, any>;
|
||||
};
|
||||
|
||||
export default class FormModel {
|
||||
fields = {};
|
||||
errors: {
|
||||
[fieldId: string]: ValidationError;
|
||||
} = {};
|
||||
handlers: LoadingListener[] = [];
|
||||
fields: Record<string, any> = {};
|
||||
errors: Record<string, ValidationError> = {};
|
||||
handlers: Array<LoadingListener> = [];
|
||||
renderErrors: boolean;
|
||||
_isLoading: boolean;
|
||||
|
||||
@ -27,7 +25,7 @@ export default class FormModel {
|
||||
this.renderErrors = options.renderErrors !== false;
|
||||
}
|
||||
|
||||
hasField(fieldId: string) {
|
||||
hasField(fieldId: string): boolean {
|
||||
return !!this.fields[fieldId];
|
||||
}
|
||||
|
||||
@ -83,7 +81,7 @@ export default class FormModel {
|
||||
*
|
||||
* @param {string} fieldId - an id of field to focus
|
||||
*/
|
||||
focus(fieldId: string) {
|
||||
focus(fieldId: string): void {
|
||||
if (!this.fields[fieldId]) {
|
||||
throw new Error(
|
||||
`Can not focus. The field with an id ${fieldId} does not exists`,
|
||||
@ -100,7 +98,7 @@ export default class FormModel {
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
value(fieldId: string) {
|
||||
value(fieldId: string): string {
|
||||
const field = this.fields[fieldId];
|
||||
|
||||
if (!field) {
|
||||
@ -124,7 +122,7 @@ export default class FormModel {
|
||||
*
|
||||
* @param {object} errors - object maping {fieldId: errorType}
|
||||
*/
|
||||
setErrors(errors: { [key: string]: ValidationError }) {
|
||||
setErrors(errors: Record<string, ValidationError>): void {
|
||||
if (typeof errors !== 'object' || errors === null) {
|
||||
throw new Error('Errors must be an object');
|
||||
}
|
||||
@ -151,21 +149,11 @@ export default class FormModel {
|
||||
return error || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error by id
|
||||
*
|
||||
* @param {string} fieldId - an id of field to get error for
|
||||
*
|
||||
* @returns {string|object|null}
|
||||
*/
|
||||
getError(fieldId: string) {
|
||||
getError(fieldId: string): ValidationError | null {
|
||||
return this.errors[fieldId] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {bool}
|
||||
*/
|
||||
hasErrors() {
|
||||
hasErrors(): boolean {
|
||||
return Object.keys(this.errors).length > 0;
|
||||
}
|
||||
|
||||
@ -174,7 +162,7 @@ export default class FormModel {
|
||||
*
|
||||
* @returns {object}
|
||||
*/
|
||||
serialize(): { [key: string]: any } {
|
||||
serialize(): Record<string, any> {
|
||||
return Object.keys(this.fields).reduce((acc, fieldId) => {
|
||||
const field = this.fields[fieldId];
|
||||
|
||||
@ -185,7 +173,7 @@ export default class FormModel {
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}, {} as Record<string, any>);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -193,7 +181,7 @@ export default class FormModel {
|
||||
*
|
||||
* @param {Function} fn
|
||||
*/
|
||||
addLoadingListener(fn: LoadingListener) {
|
||||
addLoadingListener(fn: LoadingListener): void {
|
||||
this.removeLoadingListener(fn);
|
||||
this.handlers.push(fn);
|
||||
}
|
||||
@ -203,14 +191,14 @@ export default class FormModel {
|
||||
*
|
||||
* @param {Function} fn
|
||||
*/
|
||||
removeLoadingListener(fn: LoadingListener) {
|
||||
removeLoadingListener(fn: LoadingListener): void {
|
||||
this.handlers = this.handlers.filter(handler => handler !== fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch form in loading state
|
||||
*/
|
||||
beginLoading() {
|
||||
beginLoading(): void {
|
||||
this._isLoading = true;
|
||||
this.notifyHandlers();
|
||||
}
|
||||
@ -218,12 +206,12 @@ export default class FormModel {
|
||||
/**
|
||||
* Disable loading state
|
||||
*/
|
||||
endLoading() {
|
||||
endLoading(): void {
|
||||
this._isLoading = false;
|
||||
this.notifyHandlers();
|
||||
}
|
||||
|
||||
private notifyHandlers() {
|
||||
private notifyHandlers(): void {
|
||||
this.handlers.forEach(fn => fn(this._isLoading));
|
||||
}
|
||||
}
|
||||
|
@ -22,7 +22,7 @@ describe('Input', () => {
|
||||
);
|
||||
|
||||
expect(
|
||||
wrapper.find('input[name="test"]').getDOMNode().value,
|
||||
wrapper.find('input[name="test"]').getDOMNode<HTMLInputElement>().value,
|
||||
'to equal',
|
||||
'foo',
|
||||
);
|
||||
|
@ -1,6 +1,8 @@
|
||||
import React from 'react';
|
||||
import { MessageDescriptor } from 'react-intl';
|
||||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import TextareaAutosize, {
|
||||
TextareaAutosizeProps,
|
||||
} from 'react-textarea-autosize';
|
||||
import clsx from 'clsx';
|
||||
import { uniqueId, omit } from 'app/functions';
|
||||
import { SKIN_DARK, COLOR_GREEN, Skin, Color } from 'app/components/ui';
|
||||
@ -8,22 +10,15 @@ import { SKIN_DARK, COLOR_GREEN, Skin, Color } from 'app/components/ui';
|
||||
import styles from './form.scss';
|
||||
import FormInputComponent from './FormInputComponent';
|
||||
|
||||
type TextareaAutosizeProps = {
|
||||
onHeightChange?: (number, TextareaAutosizeProps) => void;
|
||||
useCacheForDOMMeasurements?: boolean;
|
||||
minRows?: number;
|
||||
maxRows?: number;
|
||||
inputRef?: (el?: HTMLTextAreaElement) => void;
|
||||
};
|
||||
interface OwnProps {
|
||||
placeholder?: string | MessageDescriptor;
|
||||
label?: string | MessageDescriptor;
|
||||
skin: Skin;
|
||||
color: Color;
|
||||
}
|
||||
|
||||
export default class TextArea extends FormInputComponent<
|
||||
{
|
||||
placeholder?: string | MessageDescriptor;
|
||||
label?: string | MessageDescriptor;
|
||||
skin: Skin;
|
||||
color: Color;
|
||||
} & TextareaAutosizeProps &
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
OwnProps & Omit<TextareaAutosizeProps, keyof OwnProps>
|
||||
> {
|
||||
static defaultProps = {
|
||||
color: COLOR_GREEN,
|
||||
|
@ -1,26 +1,29 @@
|
||||
import React from 'react';
|
||||
import React, { ComponentType } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { Skin } from 'app/components/ui';
|
||||
|
||||
import styles from './componentLoader.scss';
|
||||
|
||||
// TODO: add mode to not show loader until first ~150ms
|
||||
|
||||
function ComponentLoader({ skin = 'dark' }: { skin?: Skin }) {
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.componentLoader, styles[`${skin}ComponentLoader`])}
|
||||
>
|
||||
<div className={styles.spins}>
|
||||
{new Array(5).fill(0).map((_, index) => (
|
||||
<div
|
||||
className={clsx(styles.spin, styles[`spin${index}`])}
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
interface Props {
|
||||
skin?: Skin;
|
||||
}
|
||||
|
||||
const ComponentLoader: ComponentType<Props> = ({ skin = 'dark' }) => (
|
||||
<div
|
||||
className={clsx(styles.componentLoader, styles[`${skin}ComponentLoader`])}
|
||||
>
|
||||
<div className={styles.spins}>
|
||||
{new Array(5).fill(0).map((_, index) => (
|
||||
<div
|
||||
className={clsx(styles.spin, styles[`spin${index}`])}
|
||||
key={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default ComponentLoader;
|
||||
|
@ -1,71 +1,64 @@
|
||||
import React from 'react';
|
||||
import React, { ComponentType, useEffect, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { ComponentLoader } from 'app/components/ui/loader';
|
||||
|
||||
import { SKIN_LIGHT } from 'app/components/ui';
|
||||
|
||||
import ComponentLoader from './ComponentLoader';
|
||||
import styles from './imageLoader.scss';
|
||||
|
||||
export default class ImageLoader extends React.Component<
|
||||
{
|
||||
src: string;
|
||||
alt: string;
|
||||
ratio: number; // width:height ratio
|
||||
onLoad?: Function;
|
||||
},
|
||||
{
|
||||
isLoading: boolean;
|
||||
}
|
||||
> {
|
||||
state = {
|
||||
isLoading: true,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.preloadImage();
|
||||
}
|
||||
|
||||
preloadImage() {
|
||||
const img = new Image();
|
||||
img.onload = () => this.imageLoaded();
|
||||
img.onerror = () => this.preloadImage();
|
||||
img.src = this.props.src;
|
||||
}
|
||||
|
||||
imageLoaded() {
|
||||
this.setState({ isLoading: false });
|
||||
|
||||
if (this.props.onLoad) {
|
||||
this.props.onLoad();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isLoading } = this.state;
|
||||
const { src, alt, ratio } = this.props;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
style={{
|
||||
height: 0,
|
||||
paddingBottom: `${ratio * 100}%`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div className={styles.loader}>
|
||||
<ComponentLoader skin={SKIN_LIGHT} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={clsx(styles.image, {
|
||||
[styles.imageLoaded]: !isLoading,
|
||||
})}
|
||||
>
|
||||
<img src={src} alt={alt} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
interface Props {
|
||||
src: string;
|
||||
alt: string;
|
||||
ratio: number; // width:height ratio
|
||||
onLoad?: () => void;
|
||||
}
|
||||
|
||||
const ImageLoader: ComponentType<Props> = ({
|
||||
src,
|
||||
alt,
|
||||
ratio,
|
||||
onLoad = () => {},
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
function preloadImage() {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
setIsLoading(false);
|
||||
onLoad();
|
||||
};
|
||||
img.onerror = preloadImage;
|
||||
img.src = src;
|
||||
}
|
||||
|
||||
preloadImage();
|
||||
}, [src]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
style={{
|
||||
height: 0,
|
||||
paddingBottom: `${ratio * 100}%`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{isLoading && (
|
||||
<div className={styles.loader}>
|
||||
<ComponentLoader skin={SKIN_LIGHT} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={clsx(styles.image, {
|
||||
[styles.imageLoaded]: !isLoading,
|
||||
})}
|
||||
>
|
||||
<img src={src} alt={alt} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageLoader;
|
||||
|
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Motion, spring } from 'react-motion';
|
||||
|
||||
import MeasureHeight from 'app/components/MeasureHeight';
|
||||
|
||||
import styles from './slide-motion.scss';
|
||||
@ -10,18 +11,19 @@ interface Props {
|
||||
}
|
||||
|
||||
interface State {
|
||||
// [stepHeight: string]: number;
|
||||
version: string;
|
||||
prevChildren: React.ReactNode | undefined;
|
||||
stepsHeights: Record<Props['activeStep'], number>;
|
||||
}
|
||||
|
||||
class SlideMotion extends React.PureComponent<Props, State> {
|
||||
state: State = {
|
||||
prevChildren: undefined, // to track version updates
|
||||
version: `${this.props.activeStep}.0`,
|
||||
stepsHeights: [],
|
||||
};
|
||||
|
||||
isHeightMeasured: boolean;
|
||||
private isHeightMeasured: boolean;
|
||||
|
||||
static getDerivedStateFromProps(props: Props, state: State) {
|
||||
let [, version] = state.version.split('.').map(Number);
|
||||
@ -42,7 +44,7 @@ class SlideMotion extends React.PureComponent<Props, State> {
|
||||
|
||||
const { version } = this.state;
|
||||
|
||||
const activeStepHeight = this.state[`step${activeStep}Height`] || 0;
|
||||
const activeStepHeight = this.state.stepsHeights[activeStep] || 0;
|
||||
|
||||
// a hack to disable height animation on first render
|
||||
const { isHeightMeasured } = this;
|
||||
@ -65,7 +67,7 @@ class SlideMotion extends React.PureComponent<Props, State> {
|
||||
|
||||
return (
|
||||
<Motion style={motionStyle}>
|
||||
{(interpolatingStyle: { height: number; transform: string }) => (
|
||||
{interpolatingStyle => (
|
||||
<div
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
@ -96,13 +98,14 @@ class SlideMotion extends React.PureComponent<Props, State> {
|
||||
);
|
||||
}
|
||||
|
||||
onStepMeasure(step: number) {
|
||||
return (height: number) =>
|
||||
// @ts-ignore
|
||||
this.setState({
|
||||
[`step${step}Height`]: height,
|
||||
});
|
||||
}
|
||||
onStepMeasure = (step: number) => (height: number) => {
|
||||
this.setState({
|
||||
stepsHeights: {
|
||||
...this.state.stepsHeights,
|
||||
[step]: height,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export default SlideMotion;
|
||||
|
@ -5,10 +5,10 @@ import React from 'react';
|
||||
|
||||
import { shallow, mount } from 'enzyme';
|
||||
|
||||
import { PopupStack } from 'app/components/ui/popup/PopupStack';
|
||||
import PopupStack from 'app/components/ui/popup/PopupStack';
|
||||
import styles from 'app/components/ui/popup/popup.scss';
|
||||
|
||||
function DummyPopup(/** @type {{[key: string]: any}} */ _props) {
|
||||
function DummyPopup(_props: Record<string, any>) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -39,7 +39,7 @@ describe('<PopupStack />', () => {
|
||||
destroy: () => {},
|
||||
popups: [
|
||||
{
|
||||
Popup: (props = {}) => {
|
||||
Popup: (props = { onClose: Function }) => {
|
||||
// eslint-disable-next-line
|
||||
expect(props.onClose, 'to be a', 'function');
|
||||
|
@ -2,16 +2,19 @@ import React from 'react';
|
||||
import { TransitionGroup, CSSTransition } from 'react-transition-group';
|
||||
import { browserHistory } from 'app/services/history';
|
||||
import { connect } from 'react-redux';
|
||||
import { Location } from 'history';
|
||||
import { RootState } from 'app/reducers';
|
||||
|
||||
import { PopupConfig } from './reducer';
|
||||
import { destroy } from './actions';
|
||||
import styles from './popup.scss';
|
||||
|
||||
export class PopupStack extends React.Component<{
|
||||
interface Props {
|
||||
popups: PopupConfig[];
|
||||
destroy: (popup: PopupConfig) => void;
|
||||
}> {
|
||||
}
|
||||
|
||||
export class PopupStack extends React.Component<Props> {
|
||||
unlistenTransition: () => void;
|
||||
|
||||
componentDidMount() {
|
||||
@ -87,7 +90,7 @@ export class PopupStack extends React.Component<{
|
||||
}
|
||||
};
|
||||
|
||||
onRouteLeave = nextLocation => {
|
||||
onRouteLeave = (nextLocation: Location) => {
|
||||
if (nextLocation) {
|
||||
this.popStack();
|
||||
}
|
||||
|
@ -1,17 +1,28 @@
|
||||
import { Action as ReduxPopup } from 'redux';
|
||||
import { PopupConfig } from './reducer';
|
||||
|
||||
export const POPUP_CREATE = 'POPUP_CREATE';
|
||||
export function create(payload: PopupConfig) {
|
||||
return {
|
||||
type: POPUP_CREATE,
|
||||
payload,
|
||||
};
|
||||
interface PopupCreateAction extends ReduxPopup {
|
||||
type: 'POPUP_CREATE';
|
||||
payload: PopupConfig;
|
||||
}
|
||||
|
||||
export const POPUP_DESTROY = 'POPUP_DESTROY';
|
||||
export function destroy(popup: PopupConfig) {
|
||||
export function create(popup: PopupConfig): PopupCreateAction {
|
||||
return {
|
||||
type: POPUP_DESTROY,
|
||||
type: 'POPUP_CREATE',
|
||||
payload: popup,
|
||||
};
|
||||
}
|
||||
|
||||
interface PopupDestroyAction extends ReduxPopup {
|
||||
type: 'POPUP_DESTROY';
|
||||
payload: PopupConfig;
|
||||
}
|
||||
|
||||
export function destroy(popup: PopupConfig): PopupDestroyAction {
|
||||
return {
|
||||
type: 'POPUP_DESTROY',
|
||||
payload: popup,
|
||||
};
|
||||
}
|
||||
|
||||
export type Action = PopupCreateAction | PopupDestroyAction;
|
||||
|
@ -1,7 +1,11 @@
|
||||
import React, { ComponentType } from 'react';
|
||||
|
||||
import expect from 'app/test/unexpected';
|
||||
import React from 'react';
|
||||
import reducer from 'app/components/ui/popup/reducer';
|
||||
import { create, destroy } from 'app/components/ui/popup/actions';
|
||||
|
||||
import reducer, { PopupConfig, State } from './reducer';
|
||||
import { create, destroy } from './actions';
|
||||
|
||||
const FakeComponent: ComponentType = () => <span />;
|
||||
|
||||
describe('popup/reducer', () => {
|
||||
it('should have no popups by default', () => {
|
||||
@ -61,12 +65,12 @@ describe('popup/reducer', () => {
|
||||
});
|
||||
|
||||
describe('#destroy', () => {
|
||||
let state;
|
||||
let popup;
|
||||
let state: State;
|
||||
let popup: PopupConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
state = reducer(state, create({ Popup: FakeComponent }));
|
||||
popup = state.popups[0];
|
||||
[popup] = state.popups;
|
||||
});
|
||||
|
||||
it('should remove popup', () => {
|
||||
@ -92,7 +96,3 @@ describe('popup/reducer', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function FakeComponent() {
|
||||
return <span />;
|
||||
}
|
@ -1,35 +1,33 @@
|
||||
import React from 'react';
|
||||
import { combineReducers } from 'redux';
|
||||
|
||||
import { POPUP_CREATE, POPUP_DESTROY } from './actions';
|
||||
import { Action } from './actions';
|
||||
|
||||
export interface PopupConfig {
|
||||
Popup: React.ElementType;
|
||||
props?: { [key: string]: any };
|
||||
// do not allow hiding popup
|
||||
props?: Record<string, any>;
|
||||
// Don't allow hiding popup
|
||||
disableOverlayClose?: boolean;
|
||||
}
|
||||
|
||||
export type State = {
|
||||
popups: PopupConfig[];
|
||||
popups: Array<PopupConfig>;
|
||||
};
|
||||
|
||||
export default combineReducers<State>({
|
||||
popups,
|
||||
});
|
||||
|
||||
function popups(state: PopupConfig[] = [], { type, payload }) {
|
||||
function popups(state: Array<PopupConfig> = [], { type, payload }: Action) {
|
||||
switch (type) {
|
||||
case POPUP_CREATE:
|
||||
case 'POPUP_CREATE':
|
||||
if (!payload.Popup) {
|
||||
throw new Error('Popup is required');
|
||||
}
|
||||
|
||||
return state.concat(payload);
|
||||
|
||||
case POPUP_DESTROY:
|
||||
case 'POPUP_DESTROY':
|
||||
return state.filter(popup => popup !== payload);
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
@ -3,16 +3,18 @@ import React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { restoreScroll } from './scroll';
|
||||
|
||||
class ScrollIntoView extends React.PureComponent<
|
||||
RouteComponentProps & {
|
||||
top?: boolean; // do not touch any DOM and simply scroll to top on location change
|
||||
}
|
||||
> {
|
||||
interface OwnProps {
|
||||
top?: boolean; // don't touch any DOM and simply scroll to top on location change
|
||||
}
|
||||
|
||||
type Props = RouteComponentProps & OwnProps;
|
||||
|
||||
class ScrollIntoView extends React.PureComponent<Props> {
|
||||
componentDidMount() {
|
||||
this.onPageUpdate();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
componentDidUpdate(prevProps: Props) {
|
||||
if (this.props.location !== prevProps.location) {
|
||||
this.onPageUpdate();
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import expect from 'app/test/unexpected';
|
||||
import { RootState } from 'app/reducers';
|
||||
|
||||
import bearerHeaderMiddleware from 'app/components/user/middlewares/bearerHeaderMiddleware';
|
||||
import { MiddlewareRequestOptions } from '../../../services/request/PromiseMiddlewareLayer';
|
||||
|
||||
describe('bearerHeaderMiddleware', () => {
|
||||
const emptyState: RootState = {
|
||||
@ -33,27 +35,29 @@ describe('bearerHeaderMiddleware', () => {
|
||||
} as any);
|
||||
|
||||
it('should set Authorization header', async () => {
|
||||
let data: any = {
|
||||
let data: MiddlewareRequestOptions = {
|
||||
url: '',
|
||||
options: {
|
||||
headers: {},
|
||||
},
|
||||
};
|
||||
|
||||
data = middleware.before && (await middleware.before(data));
|
||||
data = await middleware.before!(data);
|
||||
|
||||
expectBearerHeader(data, token);
|
||||
});
|
||||
|
||||
it('overrides user.token with options.token if available', async () => {
|
||||
const tokenOverride = 'tokenOverride';
|
||||
let data: any = {
|
||||
let data: MiddlewareRequestOptions = {
|
||||
url: '',
|
||||
options: {
|
||||
headers: {},
|
||||
token: tokenOverride,
|
||||
},
|
||||
};
|
||||
|
||||
data = middleware.before && (await middleware.before(data));
|
||||
data = await middleware.before!(data);
|
||||
|
||||
expectBearerHeader(data, tokenOverride);
|
||||
});
|
||||
@ -62,16 +66,12 @@ describe('bearerHeaderMiddleware', () => {
|
||||
const tokenOverride = null;
|
||||
const data: any = {
|
||||
options: {
|
||||
headers: {} as { [key: string]: any },
|
||||
headers: {},
|
||||
token: tokenOverride,
|
||||
},
|
||||
};
|
||||
|
||||
if (!middleware.before) {
|
||||
throw new Error('No middleware.before');
|
||||
}
|
||||
|
||||
const resp = await middleware.before(data);
|
||||
const resp = await middleware.before!(data);
|
||||
|
||||
expect(resp.options.headers.Authorization, 'to be undefined');
|
||||
});
|
||||
@ -84,22 +84,19 @@ describe('bearerHeaderMiddleware', () => {
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const data: any = {
|
||||
const data: MiddlewareRequestOptions = {
|
||||
url: '',
|
||||
options: {
|
||||
headers: {} as { [key: string]: any },
|
||||
headers: {},
|
||||
},
|
||||
};
|
||||
|
||||
if (!middleware.before) {
|
||||
throw new Error('No middleware.before');
|
||||
}
|
||||
|
||||
const resp = await middleware.before(data);
|
||||
const resp = await middleware.before!(data);
|
||||
|
||||
expect(resp.options.headers.Authorization, 'to be undefined');
|
||||
});
|
||||
|
||||
function expectBearerHeader(data, token) {
|
||||
function expectBearerHeader(data: MiddlewareRequestOptions, token: string) {
|
||||
expect(data.options.headers, 'to satisfy', {
|
||||
Authorization: `Bearer ${token}`,
|
||||
});
|
||||
|
@ -1,11 +1,13 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import expect from 'app/test/unexpected';
|
||||
import sinon from 'sinon';
|
||||
import sinon, { SinonSpy, SinonStub } from 'sinon';
|
||||
|
||||
import refreshTokenMiddleware from 'app/components/user/middlewares/refreshTokenMiddleware';
|
||||
import { browserHistory } from 'app/services/history';
|
||||
import * as authentication from 'app/services/api/authentication';
|
||||
import { InternalServerError } from 'app/services/request';
|
||||
import { InternalServerError, Middleware } from 'app/services/request';
|
||||
import { updateToken } from 'app/components/accounts/actions';
|
||||
import { MiddlewareRequestOptions } from '../../../services/request/PromiseMiddlewareLayer';
|
||||
|
||||
const refreshToken = 'foo';
|
||||
const expiredToken =
|
||||
@ -15,9 +17,9 @@ const validToken =
|
||||
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0NzA3NjE5NzcsImV4cCI6NDEwMjQ0NDgwMCwiaWF0IjoxNDcwNzYxOTc3LCJqdGkiOiJpZDEyMzQ1NiJ9.M4KY4QgHOUzhpAZjWoHJbGsEJPR-RBsJ1c1BKyxvAoU';
|
||||
|
||||
describe('refreshTokenMiddleware', () => {
|
||||
let middleware;
|
||||
let getState;
|
||||
let dispatch;
|
||||
let middleware: Middleware;
|
||||
let getState: SinonStub;
|
||||
let dispatch: SinonSpy<[any], any>;
|
||||
|
||||
const email = 'test@email.com';
|
||||
|
||||
@ -92,7 +94,7 @@ describe('refreshTokenMiddleware', () => {
|
||||
Promise.resolve(validToken),
|
||||
);
|
||||
|
||||
return middleware.before(data).then(resp => {
|
||||
return middleware.before!(data).then(resp => {
|
||||
expect(resp, 'to satisfy', data);
|
||||
|
||||
expect(authentication.requestToken, 'to have a call satisfying', [
|
||||
@ -102,8 +104,13 @@ describe('refreshTokenMiddleware', () => {
|
||||
});
|
||||
|
||||
it('should not apply to refresh-token request', () => {
|
||||
const data = { url: '/refresh-token', options: {} };
|
||||
const resp = middleware.before(data);
|
||||
const data: MiddlewareRequestOptions = {
|
||||
url: '/refresh-token',
|
||||
options: {
|
||||
headers: {},
|
||||
},
|
||||
};
|
||||
const resp = middleware.before!(data);
|
||||
|
||||
return expect(resp, 'to be fulfilled with', data).then(() =>
|
||||
expect(authentication.requestToken, 'was not called'),
|
||||
@ -111,11 +118,14 @@ describe('refreshTokenMiddleware', () => {
|
||||
});
|
||||
|
||||
it('should not auto refresh token if options.token specified', () => {
|
||||
const data = {
|
||||
const data: MiddlewareRequestOptions = {
|
||||
url: 'foo',
|
||||
options: { token: 'foo' },
|
||||
options: {
|
||||
token: 'foo',
|
||||
headers: {},
|
||||
},
|
||||
};
|
||||
middleware.before(data);
|
||||
middleware.before!(data);
|
||||
|
||||
expect(authentication.requestToken, 'was not called');
|
||||
});
|
||||
@ -132,13 +142,11 @@ describe('refreshTokenMiddleware', () => {
|
||||
Promise.resolve(validToken),
|
||||
);
|
||||
|
||||
return middleware
|
||||
.before(data)
|
||||
.then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
updateToken(validToken),
|
||||
]),
|
||||
);
|
||||
return middleware.before!(data).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
updateToken(validToken),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should relogin if token can not be parsed', () => {
|
||||
@ -159,9 +167,12 @@ describe('refreshTokenMiddleware', () => {
|
||||
user: {},
|
||||
});
|
||||
|
||||
const req = { url: 'foo', options: {} };
|
||||
const req: MiddlewareRequestOptions = {
|
||||
url: 'foo',
|
||||
options: { headers: {} },
|
||||
};
|
||||
|
||||
return expect(middleware.before(req), 'to be rejected with', {
|
||||
return expect(middleware.before!(req), 'to be rejected with', {
|
||||
message: 'Invalid token',
|
||||
}).then(() => {
|
||||
expect(authentication.requestToken, 'was not called');
|
||||
@ -174,7 +185,7 @@ describe('refreshTokenMiddleware', () => {
|
||||
(authentication.requestToken as any).returns(Promise.reject());
|
||||
|
||||
return expect(
|
||||
middleware.before({ url: 'foo', options: {} }),
|
||||
middleware.before!({ url: 'foo', options: { headers: {} } }),
|
||||
'to be rejected',
|
||||
).then(() => assertRelogin());
|
||||
});
|
||||
@ -185,7 +196,7 @@ describe('refreshTokenMiddleware', () => {
|
||||
(authentication.requestToken as any).returns(Promise.reject(resp));
|
||||
|
||||
return expect(
|
||||
middleware.before({ url: 'foo', options: {} }),
|
||||
middleware.before!({ url: 'foo', options: { headers: {} } }),
|
||||
'to be rejected with',
|
||||
resp,
|
||||
).then(() =>
|
||||
@ -205,11 +216,13 @@ describe('refreshTokenMiddleware', () => {
|
||||
user: {},
|
||||
});
|
||||
|
||||
const data = {
|
||||
const data: MiddlewareRequestOptions = {
|
||||
url: 'foo',
|
||||
options: {},
|
||||
options: {
|
||||
headers: {},
|
||||
},
|
||||
};
|
||||
const resp = middleware.before(data);
|
||||
const resp = middleware.before!(data);
|
||||
|
||||
return expect(resp, 'to be fulfilled with', data).then(() =>
|
||||
expect(authentication.requestToken, 'was not called'),
|
||||
@ -242,7 +255,7 @@ describe('refreshTokenMiddleware', () => {
|
||||
type: 'yii\\web\\UnauthorizedHttpException',
|
||||
};
|
||||
|
||||
let restart;
|
||||
let restart: SinonStub;
|
||||
|
||||
beforeEach(() => {
|
||||
getState.returns({
|
||||
@ -275,19 +288,31 @@ describe('refreshTokenMiddleware', () => {
|
||||
|
||||
it('should request new token if expired', () =>
|
||||
expect(
|
||||
middleware.catch(expiredResponse, { options: {} }, restart),
|
||||
middleware.catch!(
|
||||
expiredResponse,
|
||||
{ url: '', options: { headers: {} } },
|
||||
restart,
|
||||
),
|
||||
'to be fulfilled',
|
||||
).then(assertNewTokenRequest));
|
||||
|
||||
it('should request new token if invalid credential', () =>
|
||||
expect(
|
||||
middleware.catch(badTokenReponse, { options: {} }, restart),
|
||||
middleware.catch!(
|
||||
badTokenReponse,
|
||||
{ url: '', options: { headers: {} } },
|
||||
restart,
|
||||
),
|
||||
'to be fulfilled',
|
||||
).then(assertNewTokenRequest));
|
||||
|
||||
it('should request new token if token is incorrect', () =>
|
||||
expect(
|
||||
middleware.catch(incorrectTokenReponse, { options: {} }, restart),
|
||||
middleware.catch!(
|
||||
incorrectTokenReponse,
|
||||
{ url: '', options: { headers: {} } },
|
||||
restart,
|
||||
),
|
||||
'to be fulfilled',
|
||||
).then(assertNewTokenRequest));
|
||||
|
||||
@ -310,7 +335,11 @@ describe('refreshTokenMiddleware', () => {
|
||||
});
|
||||
|
||||
return expect(
|
||||
middleware.catch(incorrectTokenReponse, { options: {} }, restart),
|
||||
middleware.catch!(
|
||||
incorrectTokenReponse,
|
||||
{ url: '', options: { headers: {} } },
|
||||
restart,
|
||||
),
|
||||
'to be rejected',
|
||||
).then(() => {
|
||||
assertRelogin();
|
||||
@ -318,11 +347,13 @@ describe('refreshTokenMiddleware', () => {
|
||||
});
|
||||
|
||||
it('should pass the request through if options.token specified', () => {
|
||||
const promise = middleware.catch(
|
||||
const promise = middleware.catch!(
|
||||
expiredResponse,
|
||||
{
|
||||
url: '',
|
||||
options: {
|
||||
token: 'foo',
|
||||
headers: {},
|
||||
},
|
||||
},
|
||||
restart,
|
||||
@ -339,10 +370,13 @@ describe('refreshTokenMiddleware', () => {
|
||||
it('should pass the rest of failed requests through', () => {
|
||||
const resp = {};
|
||||
|
||||
const promise = middleware.catch(
|
||||
const promise = middleware.catch!(
|
||||
resp,
|
||||
{
|
||||
options: {},
|
||||
url: '',
|
||||
options: {
|
||||
headers: {},
|
||||
},
|
||||
},
|
||||
restart,
|
||||
);
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { MouseEventHandler } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { AccountSwitcher } from 'app/components/accounts';
|
||||
|
||||
@ -21,6 +21,7 @@ export default class LoggedInPanel extends React.Component<
|
||||
|
||||
componentDidMount() {
|
||||
if (window.document) {
|
||||
// @ts-ignore
|
||||
window.document.addEventListener('click', this.onBodyClick);
|
||||
}
|
||||
|
||||
@ -29,6 +30,7 @@ export default class LoggedInPanel extends React.Component<
|
||||
|
||||
componentWillUnmount() {
|
||||
if (window.document) {
|
||||
// @ts-ignore
|
||||
window.document.removeEventListener('click', this.onBodyClick);
|
||||
}
|
||||
|
||||
@ -105,15 +107,15 @@ function createOnOutsideComponentClickHandler(
|
||||
getEl: () => HTMLElement | null,
|
||||
isActive: () => boolean,
|
||||
callback: () => void,
|
||||
) {
|
||||
): MouseEventHandler {
|
||||
// TODO: we have the same logic in LangMenu
|
||||
// Probably we should decouple this into some helper function
|
||||
// TODO: the name of function may be better...
|
||||
return (event: { target: HTMLElement } & MouseEvent) => {
|
||||
return event => {
|
||||
const el = getEl();
|
||||
|
||||
if (isActive() && el) {
|
||||
if (!el.contains(event.target) && el !== event.target) {
|
||||
if (!el.contains(event.target as HTMLElement) && el !== event.target) {
|
||||
event.preventDefault();
|
||||
|
||||
// add a small delay for the case someone have alredy called toggle
|
||||
|
13
packages/app/i18n/index.d.ts
vendored
Normal file
13
packages/app/i18n/index.d.ts
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
declare module 'app/i18n' {
|
||||
export interface Locale {
|
||||
code: string;
|
||||
name: string;
|
||||
englishName: string;
|
||||
progress: number;
|
||||
isReleased: boolean;
|
||||
}
|
||||
|
||||
const LANGS: Record<string, Locale>;
|
||||
|
||||
export default LANGS;
|
||||
}
|
@ -7,12 +7,13 @@ import { factory as userFactory } from 'app/components/user/factory';
|
||||
import authFlow from 'app/services/authFlow';
|
||||
import storeFactory from 'app/storeFactory';
|
||||
import bsodFactory from 'app/components/ui/bsod/factory';
|
||||
import loader from 'app/services/loader';
|
||||
import * as loader from 'app/services/loader';
|
||||
import logger from 'app/services/logger';
|
||||
import font from 'app/services/font';
|
||||
import history, { browserHistory } from 'app/services/history';
|
||||
import i18n from 'app/services/i18n';
|
||||
import { loadScript, debounce } from 'app/functions';
|
||||
import { Location as HistoryLocation } from 'history';
|
||||
|
||||
import App from './shell/App';
|
||||
|
||||
@ -75,7 +76,7 @@ function initAnalytics() {
|
||||
}
|
||||
}
|
||||
|
||||
function _trackPageView(location) {
|
||||
function _trackPageView(location: HistoryLocation | Location): void {
|
||||
const { ga } = win;
|
||||
|
||||
ga('set', 'page', location.pathname + location.search);
|
||||
|
@ -36,9 +36,15 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/debounce": "^1.2.0",
|
||||
"@types/enzyme": "^3.8.0",
|
||||
"@types/intl": "^1.2.0",
|
||||
"@types/promise.prototype.finally": "^2.0.3",
|
||||
"@types/raf": "^3.4.0",
|
||||
"@types/react-dom": "^16.9.4",
|
||||
"@types/react-helmet": "^5.0.15",
|
||||
"@types/react-motion": "^0.0.29",
|
||||
"@types/react-transition-group": "^4.2.3",
|
||||
"@types/webfontloader": "^1.6.29",
|
||||
"@types/webpack-env": "^1.15.0",
|
||||
"enzyme": "^3.8.0",
|
||||
"enzyme-adapter-react-16": "^1.15.1"
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
@ -8,48 +8,48 @@ import styles from './404.scss';
|
||||
import messages from './PageNotFound.intl.json';
|
||||
import profileStyles from '../profile/profile.scss';
|
||||
|
||||
export default function PageNotFound() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Message {...messages.title}>
|
||||
{pageTitle => <Helmet title={pageTitle as string} />}
|
||||
</Message>
|
||||
const PageNotFound: ComponentType = () => (
|
||||
<div className={styles.page}>
|
||||
<Message {...messages.title}>
|
||||
{pageTitle => <Helmet title={pageTitle as string} />}
|
||||
</Message>
|
||||
|
||||
<div className={styles.loading}>
|
||||
<div className={styles.cube} />
|
||||
<div className={styles.road} />
|
||||
<div className={styles.rocks}>
|
||||
<span className={styles.rockOne} />
|
||||
<span className={styles.rockTwo} />
|
||||
<span className={styles.rockThree} />
|
||||
<span className={styles.rockFour} />
|
||||
<span className={styles.rockFive} />
|
||||
</div>
|
||||
<div className={styles.clouds}>
|
||||
<span className={styles.cloudOne} />
|
||||
<span className={styles.cloudTwo} />
|
||||
<span className={styles.cloudThree} />
|
||||
</div>
|
||||
<div className={styles.loading}>
|
||||
<div className={styles.cube} />
|
||||
<div className={styles.road} />
|
||||
<div className={styles.rocks}>
|
||||
<span className={styles.rockOne} />
|
||||
<span className={styles.rockTwo} />
|
||||
<span className={styles.rockThree} />
|
||||
<span className={styles.rockFour} />
|
||||
<span className={styles.rockFive} />
|
||||
</div>
|
||||
<p className={styles.text}>
|
||||
<Message {...messages.nothingHere} />
|
||||
</p>
|
||||
<p className={styles.subText}>
|
||||
<Message
|
||||
{...messages.returnToTheHomePage}
|
||||
values={{
|
||||
link: (
|
||||
<Link to="/">
|
||||
<Message {...messages.homePage} />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<div className={profileStyles.footer}>
|
||||
<FooterMenu />
|
||||
<div className={styles.clouds}>
|
||||
<span className={styles.cloudOne} />
|
||||
<span className={styles.cloudTwo} />
|
||||
<span className={styles.cloudThree} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<p className={styles.text}>
|
||||
<Message {...messages.nothingHere} />
|
||||
</p>
|
||||
<p className={styles.subText}>
|
||||
<Message
|
||||
{...messages.returnToTheHomePage}
|
||||
values={{
|
||||
link: (
|
||||
<Link to="/">
|
||||
<Message {...messages.homePage} />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<div className={profileStyles.footer}>
|
||||
<FooterMenu />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PageNotFound;
|
||||
|
@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Route, Switch, Redirect } from 'react-router-dom';
|
||||
import React, { ComponentType, ReactNode, useCallback, useState } from 'react';
|
||||
import { Route, Switch, Redirect, RouteComponentProps } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import AppInfo from 'app/components/auth/appInfo/AppInfo';
|
||||
import PanelTransition from 'app/components/auth/PanelTransition';
|
||||
import Register from 'app/components/auth/register/Register';
|
||||
@ -14,10 +16,9 @@ import ForgotPassword from 'app/components/auth/forgotPassword/ForgotPassword';
|
||||
import RecoverPassword from 'app/components/auth/recoverPassword/RecoverPassword';
|
||||
import Mfa from 'app/components/auth/mfa/Mfa';
|
||||
import Finish from 'app/components/auth/finish/Finish';
|
||||
import { connect } from 'react-redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
|
||||
import { RootState } from 'app/reducers';
|
||||
import { Client } from 'app/components/auth/reducer';
|
||||
import { Factory } from 'app/components/auth/factory';
|
||||
|
||||
import styles from './auth.scss';
|
||||
|
||||
@ -27,88 +28,72 @@ import styles from './auth.scss';
|
||||
// so that it persist disregarding remounts
|
||||
let isSidebarHiddenCache = false;
|
||||
|
||||
interface Props {
|
||||
client: Client | null;
|
||||
}
|
||||
const AuthPage: ComponentType = () => {
|
||||
const [isSidebarHidden, setIsSidebarHidden] = useState<boolean>(
|
||||
isSidebarHiddenCache,
|
||||
);
|
||||
const client = useSelector((state: RootState) => state.auth.client);
|
||||
|
||||
class AuthPage extends React.Component<
|
||||
Props,
|
||||
{
|
||||
isSidebarHidden: boolean;
|
||||
}
|
||||
> {
|
||||
state = {
|
||||
isSidebarHidden: isSidebarHiddenCache,
|
||||
};
|
||||
|
||||
render() {
|
||||
const { isSidebarHidden } = this.state;
|
||||
const { client } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className={isSidebarHidden ? styles.hiddenSidebar : styles.sidebar}
|
||||
>
|
||||
<AppInfo {...client} onGoToAuth={this.onGoToAuth} />
|
||||
</div>
|
||||
|
||||
<div className={styles.content} data-e2e-content>
|
||||
<Switch>
|
||||
<Route path="/login" render={renderPanelTransition(Login)} />
|
||||
<Route path="/mfa" render={renderPanelTransition(Mfa)} />
|
||||
<Route path="/password" render={renderPanelTransition(Password)} />
|
||||
<Route path="/register" render={renderPanelTransition(Register)} />
|
||||
<Route
|
||||
path="/activation/:key?"
|
||||
render={renderPanelTransition(Activation)}
|
||||
/>
|
||||
<Route
|
||||
path="/resend-activation"
|
||||
render={renderPanelTransition(ResendActivation)}
|
||||
/>
|
||||
<Route
|
||||
path="/oauth/permissions"
|
||||
render={renderPanelTransition(Permissions)}
|
||||
/>
|
||||
<Route
|
||||
path="/choose-account"
|
||||
render={renderPanelTransition(ChooseAccount)}
|
||||
/>
|
||||
<Route
|
||||
path="/oauth/choose-account"
|
||||
render={renderPanelTransition(ChooseAccount)}
|
||||
/>
|
||||
<Route path="/oauth/finish" component={Finish} />
|
||||
<Route
|
||||
path="/accept-rules"
|
||||
render={renderPanelTransition(AcceptRules)}
|
||||
/>
|
||||
<Route
|
||||
path="/forgot-password"
|
||||
render={renderPanelTransition(ForgotPassword)}
|
||||
/>
|
||||
<Route
|
||||
path="/recover-password/:key?"
|
||||
render={renderPanelTransition(RecoverPassword)}
|
||||
/>
|
||||
<Redirect to="/404" />
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onGoToAuth = () => {
|
||||
const goToAuth = useCallback(() => {
|
||||
isSidebarHiddenCache = true;
|
||||
setIsSidebarHidden(true);
|
||||
}, []);
|
||||
|
||||
this.setState({
|
||||
isSidebarHidden: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<div className={isSidebarHidden ? styles.hiddenSidebar : styles.sidebar}>
|
||||
<AppInfo {...client} onGoToAuth={goToAuth} />
|
||||
</div>
|
||||
|
||||
function renderPanelTransition(factory) {
|
||||
<div className={styles.content} data-e2e-content>
|
||||
<Switch>
|
||||
<Route path="/login" render={renderPanelTransition(Login)} />
|
||||
<Route path="/mfa" render={renderPanelTransition(Mfa)} />
|
||||
<Route path="/password" render={renderPanelTransition(Password)} />
|
||||
<Route path="/register" render={renderPanelTransition(Register)} />
|
||||
<Route
|
||||
path="/activation/:key?"
|
||||
render={renderPanelTransition(Activation)}
|
||||
/>
|
||||
<Route
|
||||
path="/resend-activation"
|
||||
render={renderPanelTransition(ResendActivation)}
|
||||
/>
|
||||
<Route
|
||||
path="/oauth/permissions"
|
||||
render={renderPanelTransition(Permissions)}
|
||||
/>
|
||||
<Route
|
||||
path="/choose-account"
|
||||
render={renderPanelTransition(ChooseAccount)}
|
||||
/>
|
||||
<Route
|
||||
path="/oauth/choose-account"
|
||||
render={renderPanelTransition(ChooseAccount)}
|
||||
/>
|
||||
<Route path="/oauth/finish" component={Finish} />
|
||||
<Route
|
||||
path="/accept-rules"
|
||||
render={renderPanelTransition(AcceptRules)}
|
||||
/>
|
||||
<Route
|
||||
path="/forgot-password"
|
||||
render={renderPanelTransition(ForgotPassword)}
|
||||
/>
|
||||
<Route
|
||||
path="/recover-password/:key?"
|
||||
render={renderPanelTransition(RecoverPassword)}
|
||||
/>
|
||||
<Redirect to="/404" />
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function renderPanelTransition(
|
||||
factory: Factory,
|
||||
): (props: RouteComponentProps<any>) => ReactNode {
|
||||
const { Title, Body, Footer, Links } = factory();
|
||||
|
||||
return props => (
|
||||
@ -122,8 +107,4 @@ function renderPanelTransition(factory) {
|
||||
);
|
||||
}
|
||||
|
||||
export default withRouter(
|
||||
connect((state: RootState) => ({
|
||||
client: state.auth.client,
|
||||
}))(AuthPage),
|
||||
);
|
||||
export default AuthPage;
|
||||
|
@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import loader from 'app/services/loader';
|
||||
import * as loader from 'app/services/loader';
|
||||
import { Query } from 'app/services/request';
|
||||
|
||||
import rootMessages from '../root/RootPage.intl.json';
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { ComponentType } from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router-dom';
|
||||
import { FooterMenu } from 'app/components/footerMenu';
|
||||
import PrivateRoute from 'app/containers/PrivateRoute';
|
||||
@ -8,32 +8,32 @@ import ApplicationsListPage from './ApplicationsListPage';
|
||||
import CreateNewApplicationPage from './CreateNewApplicationPage';
|
||||
import UpdateApplicationPage from './UpdateApplicationPage';
|
||||
|
||||
export default function DevPage() {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div data-e2e-content>
|
||||
<Switch>
|
||||
<Route
|
||||
path="/dev/applications"
|
||||
exact
|
||||
component={ApplicationsListPage}
|
||||
/>
|
||||
<PrivateRoute
|
||||
path="/dev/applications/new"
|
||||
exact
|
||||
component={CreateNewApplicationPage}
|
||||
/>
|
||||
<PrivateRoute
|
||||
path="/dev/applications/:clientId"
|
||||
component={UpdateApplicationPage}
|
||||
/>
|
||||
<Redirect to="/dev/applications" />
|
||||
</Switch>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<FooterMenu />
|
||||
</div>
|
||||
const DevPage: ComponentType = () => (
|
||||
<div className={styles.container}>
|
||||
<div data-e2e-content>
|
||||
<Switch>
|
||||
<Route
|
||||
path="/dev/applications"
|
||||
exact
|
||||
component={ApplicationsListPage}
|
||||
/>
|
||||
<PrivateRoute
|
||||
path="/dev/applications/new"
|
||||
exact
|
||||
component={CreateNewApplicationPage}
|
||||
/>
|
||||
<PrivateRoute
|
||||
path="/dev/applications/:clientId"
|
||||
component={UpdateApplicationPage}
|
||||
/>
|
||||
<Redirect to="/dev/applications" />
|
||||
</Switch>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<FooterMenu />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default DevPage;
|
||||
|
@ -5,7 +5,7 @@ import { RouteComponentProps } from 'react-router';
|
||||
import { FormModel } from 'app/components/ui/form';
|
||||
import { browserHistory } from 'app/services/history';
|
||||
import oauth from 'app/services/api/oauth';
|
||||
import loader from 'app/services/loader';
|
||||
import * as loader from 'app/services/loader';
|
||||
import PageNotFound from 'app/pages/404/PageNotFound';
|
||||
import {
|
||||
getApp,
|
||||
|
@ -83,7 +83,9 @@ class ChangeEmailPage extends React.Component<Props> {
|
||||
};
|
||||
}
|
||||
|
||||
function handleErrors(repeatUrl: string | void) {
|
||||
function handleErrors(
|
||||
repeatUrl?: string,
|
||||
): <T extends { errors: Record<string, any> }>(resp: T) => Promise<T> {
|
||||
return resp => {
|
||||
if (resp.errors) {
|
||||
if (resp.errors.key) {
|
||||
|
@ -70,6 +70,6 @@ export default connect(
|
||||
username: state.user.username,
|
||||
}),
|
||||
{
|
||||
updateUsername: username => updateUser({ username }),
|
||||
updateUsername: (username: string) => updateUser({ username }),
|
||||
},
|
||||
)(ChangeUsernamePage);
|
||||
|
@ -60,7 +60,7 @@ class MultiFactorAuthPage extends React.Component<Props> {
|
||||
return step;
|
||||
}
|
||||
|
||||
onChangeStep = (step: MfaStep) => {
|
||||
onChangeStep = (step: number) => {
|
||||
this.props.history.push(`/profile/mfa/step${step + 1}`);
|
||||
};
|
||||
|
||||
|
@ -8,7 +8,7 @@ import logger from 'app/services/logger';
|
||||
import { browserHistory } from 'app/services/history';
|
||||
import { FooterMenu } from 'app/components/footerMenu';
|
||||
import { FormModel } from 'app/components/ui/form';
|
||||
import { RootState } from 'app/reducers';
|
||||
import { Dispatch, RootState } from 'app/reducers';
|
||||
import { Provider } from 'app/components/profile/Context';
|
||||
import { ComponentLoader } from 'app/components/ui/loader';
|
||||
|
||||
@ -110,7 +110,7 @@ class ProfilePage extends React.Component<Props> {
|
||||
|
||||
export default connect(
|
||||
(state: RootState) => ({
|
||||
userId: state.user.id,
|
||||
userId: state.user.id!,
|
||||
}),
|
||||
{
|
||||
refreshUserData,
|
||||
@ -120,7 +120,7 @@ export default connect(
|
||||
}: {
|
||||
form: FormModel;
|
||||
sendData: () => Promise<any>;
|
||||
}) => dispatch => {
|
||||
}) => (dispatch: Dispatch) => {
|
||||
form.beginLoading();
|
||||
|
||||
return sendData()
|
||||
@ -165,7 +165,7 @@ export default connect(
|
||||
})
|
||||
.finally(() => form.endLoading());
|
||||
|
||||
function requestPassword(form) {
|
||||
function requestPassword(form: FormModel) {
|
||||
return new Promise((resolve, reject) => {
|
||||
dispatch(
|
||||
createPopup({
|
||||
|
@ -11,7 +11,7 @@ import PrivateRoute from 'app/containers/PrivateRoute';
|
||||
import AuthFlowRoute from 'app/containers/AuthFlowRoute';
|
||||
import Userbar from 'app/components/userbar/Userbar';
|
||||
import PopupStack from 'app/components/ui/popup/PopupStack';
|
||||
import loader from 'app/services/loader';
|
||||
import * as loader from 'app/services/loader';
|
||||
import { getActiveAccount } from 'app/components/accounts/reducer';
|
||||
import { User } from 'app/components/user';
|
||||
import { Account } from 'app/components/accounts/reducer';
|
||||
|
@ -1,17 +1,23 @@
|
||||
import React from 'react';
|
||||
import React, { ComponentProps } from 'react';
|
||||
import sinon from 'sinon';
|
||||
import expect from 'app/test/unexpected';
|
||||
import { shallow } from 'enzyme';
|
||||
import { shallow, ShallowWrapper } from 'enzyme';
|
||||
|
||||
import RulesPage from './RulesPage';
|
||||
|
||||
type RulesPageShallowType = ShallowWrapper<
|
||||
ComponentProps<typeof RulesPage>,
|
||||
any,
|
||||
RulesPage
|
||||
>;
|
||||
|
||||
describe('RulesPage', () => {
|
||||
describe('#onRuleClick()', () => {
|
||||
const id = 'rule-1-2';
|
||||
const pathname = '/foo';
|
||||
const search = '?bar';
|
||||
let page;
|
||||
let replace;
|
||||
let page: RulesPageShallowType;
|
||||
let replace: Function;
|
||||
|
||||
beforeEach(() => {
|
||||
replace = sinon.stub().named('history.replace');
|
||||
|
@ -2,17 +2,17 @@ import 'core-js/stable';
|
||||
import 'regenerator-runtime/runtime';
|
||||
import 'url-search-params-polyfill';
|
||||
import 'whatwg-fetch';
|
||||
import { shim as shimPromiseFinaly } from 'promise.prototype.finally';
|
||||
import { shim as shimPromiseFinally } from 'promise.prototype.finally';
|
||||
import { polyfill as rafPolyfill } from 'raf';
|
||||
|
||||
rafPolyfill();
|
||||
shimPromiseFinaly();
|
||||
shimPromiseFinally();
|
||||
|
||||
// allow :active styles in mobile Safary
|
||||
document.addEventListener('touchstart', () => {}, true);
|
||||
|
||||
// disable mobile safary back-forward cache
|
||||
// http://stackoverflow.com/questions/8788802/prevent-safari-loading-from-cache-when-back-button-is-clicked
|
||||
window.onpageshow = event => {
|
||||
window.onpageshow = (event: PageTransitionEvent) => {
|
||||
event.persisted && window.location.reload();
|
||||
};
|
@ -2,7 +2,7 @@ import expect from 'app/test/unexpected';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import request from 'app/services/request';
|
||||
import signup from 'app/services/api/signup';
|
||||
import * as signup from 'app/services/api/signup';
|
||||
|
||||
describe('signup api', () => {
|
||||
describe('#register', () => {
|
||||
@ -46,9 +46,7 @@ describe('signup api', () => {
|
||||
});
|
||||
|
||||
describe('#activate', () => {
|
||||
const params = {
|
||||
key: 'key',
|
||||
};
|
||||
const key = 'key';
|
||||
|
||||
beforeEach(() => {
|
||||
sinon.stub(request, 'post').named('request.post');
|
||||
@ -59,21 +57,21 @@ describe('signup api', () => {
|
||||
});
|
||||
|
||||
it('should post to confirmation api', () => {
|
||||
signup.activate(params);
|
||||
signup.activate(key);
|
||||
|
||||
expect(request.post, 'to have a call satisfying', [
|
||||
'/api/signup/confirm',
|
||||
params,
|
||||
{ key },
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should disable any token', () => {
|
||||
signup.activate(params);
|
||||
signup.activate(key);
|
||||
|
||||
expect(request.post, 'to have a call satisfying', [
|
||||
'/api/signup/confirm',
|
||||
params,
|
||||
{ key },
|
||||
{ token: null },
|
||||
]);
|
||||
});
|
||||
|
@ -1,28 +1,18 @@
|
||||
/* eslint-disable @typescript-eslint/camelcase */
|
||||
import expect from 'app/test/unexpected';
|
||||
import sinon from 'sinon';
|
||||
import sinon, { SinonFakeServer } from 'sinon';
|
||||
|
||||
import request from 'app/services/request';
|
||||
import * as authentication from 'app/services/api/authentication';
|
||||
import * as accounts from 'app/services/api/accounts';
|
||||
|
||||
describe('authentication api', () => {
|
||||
let server;
|
||||
let server: SinonFakeServer;
|
||||
|
||||
beforeEach(() => {
|
||||
server = sinon.createFakeServer({
|
||||
server = sinon.fakeServer.create({
|
||||
autoRespond: true,
|
||||
});
|
||||
|
||||
['get', 'post'].forEach(method => {
|
||||
server[method] = (url, resp = {}, status = 200, headers = {}) => {
|
||||
server.respondWith(method, url, [
|
||||
status,
|
||||
{ 'Content-Type': 'application/json', ...headers },
|
||||
JSON.stringify(resp),
|
||||
]);
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -156,12 +146,16 @@ describe('authentication api', () => {
|
||||
});
|
||||
|
||||
it('resolves with new token and user object', async () => {
|
||||
server.post('/api/authentication/refresh-token', {
|
||||
access_token: newToken,
|
||||
refresh_token: validRefreshToken,
|
||||
success: true,
|
||||
expires_in: 50000,
|
||||
});
|
||||
server.respondWith(
|
||||
'POST',
|
||||
'/api/authentication/refresh-token',
|
||||
JSON.stringify({
|
||||
access_token: newToken,
|
||||
refresh_token: validRefreshToken,
|
||||
success: true,
|
||||
expires_in: 50000,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
authentication.validateToken(...validateTokenArgs),
|
||||
@ -178,7 +172,11 @@ describe('authentication api', () => {
|
||||
|
||||
it('rejects if token request failed', () => {
|
||||
const error = { error: 'Unexpected error example' };
|
||||
server.post('/api/authentication/refresh-token', error, 500);
|
||||
server.respondWith('POST', '/api/authentication/refresh-token', [
|
||||
500,
|
||||
[],
|
||||
JSON.stringify(error),
|
||||
]);
|
||||
|
||||
return expect(
|
||||
authentication.validateToken(...validateTokenArgs),
|
||||
@ -205,12 +203,16 @@ describe('authentication api', () => {
|
||||
});
|
||||
|
||||
it('resolves with new token and user object', async () => {
|
||||
server.post('/api/authentication/refresh-token', {
|
||||
access_token: newToken,
|
||||
refresh_token: validRefreshToken,
|
||||
success: true,
|
||||
expires_in: 50000,
|
||||
});
|
||||
server.respondWith(
|
||||
'POST',
|
||||
'/api/authentication/refresh-token',
|
||||
JSON.stringify({
|
||||
access_token: newToken,
|
||||
refresh_token: validRefreshToken,
|
||||
success: true,
|
||||
expires_in: 50000,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
authentication.validateToken(...validateTokenArgs),
|
||||
@ -227,7 +229,11 @@ describe('authentication api', () => {
|
||||
|
||||
it('rejects if token request failed', () => {
|
||||
const error = { error: 'Unexpected error example' };
|
||||
server.post('/api/authentication/refresh-token', error, 500);
|
||||
server.respondWith('POST', '/api/authentication/refresh-token', [
|
||||
500,
|
||||
[],
|
||||
JSON.stringify(error),
|
||||
]);
|
||||
|
||||
return expect(
|
||||
authentication.validateToken(...validateTokenArgs),
|
||||
|
@ -8,13 +8,13 @@ export type Scope =
|
||||
| 'account_info'
|
||||
| 'account_email';
|
||||
|
||||
export type Client = {
|
||||
export interface Client {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type OauthAppResponse = {
|
||||
export interface OauthAppResponse {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
type: ApplicationType;
|
||||
@ -27,9 +27,9 @@ export type OauthAppResponse = {
|
||||
redirectUri?: string;
|
||||
// fields for 'minecraft-server' type
|
||||
minecraftServerIp?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type OauthRequestData = {
|
||||
interface OauthRequestData {
|
||||
client_id: string;
|
||||
redirect_uri: string;
|
||||
response_type: string;
|
||||
@ -38,37 +38,43 @@ type OauthRequestData = {
|
||||
prompt: string;
|
||||
login_hint?: string;
|
||||
state?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export type OauthData = {
|
||||
export interface OauthData {
|
||||
clientId: string;
|
||||
redirectUrl: string;
|
||||
responseType: string;
|
||||
description?: string;
|
||||
scope: string;
|
||||
// TODO: why prompt is not nullable?
|
||||
prompt: string; // comma separated list of 'none' | 'consent' | 'select_account';
|
||||
loginHint?: string;
|
||||
state?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type FormPayloads = {
|
||||
export interface OAuthValidateResponse {
|
||||
session: {
|
||||
scopes: Scope[];
|
||||
};
|
||||
client: Client;
|
||||
oAuth: {}; // TODO: improve typing
|
||||
}
|
||||
|
||||
interface FormPayloads {
|
||||
name?: string;
|
||||
description?: string;
|
||||
websiteUrl?: string;
|
||||
redirectUri?: string;
|
||||
minecraftServerIp?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const api = {
|
||||
validate(oauthData: OauthData) {
|
||||
return request
|
||||
.get<{
|
||||
session: {
|
||||
scopes: Scope[];
|
||||
};
|
||||
client: Client;
|
||||
oAuth: {};
|
||||
}>('/api/oauth2/v1/validate', getOAuthRequest(oauthData))
|
||||
.get<OAuthValidateResponse>(
|
||||
'/api/oauth2/v1/validate',
|
||||
getOAuthRequest(oauthData),
|
||||
)
|
||||
.catch(handleOauthParamsValidation);
|
||||
},
|
||||
|
||||
|
@ -10,7 +10,12 @@ describe('services/api/options', () => {
|
||||
sinon
|
||||
.stub(request, 'get')
|
||||
.named('request.get')
|
||||
.returns(Promise.resolve(expectedResp));
|
||||
.returns(
|
||||
Promise.resolve({
|
||||
originalResponse: new Response(),
|
||||
...expectedResp,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
@ -1,33 +1,37 @@
|
||||
import request from 'app/services/request';
|
||||
import request, { Resp } from 'app/services/request';
|
||||
|
||||
import { OAuthResponse } from './authentication';
|
||||
|
||||
export default {
|
||||
register({
|
||||
email = '',
|
||||
username = '',
|
||||
password = '',
|
||||
rePassword = '',
|
||||
rulesAgreement = false,
|
||||
lang = '',
|
||||
captcha = '',
|
||||
}) {
|
||||
return request.post(
|
||||
'/api/signup',
|
||||
{ email, username, password, rePassword, rulesAgreement, lang, captcha },
|
||||
{ token: null },
|
||||
);
|
||||
},
|
||||
interface RegisterParams {
|
||||
email?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
rePassword?: string;
|
||||
rulesAgreement?: boolean;
|
||||
lang?: string;
|
||||
captcha?: string;
|
||||
}
|
||||
|
||||
activate({ key = '' }) {
|
||||
return request.post<OAuthResponse>(
|
||||
'/api/signup/confirm',
|
||||
{ key },
|
||||
{ token: null },
|
||||
);
|
||||
},
|
||||
export function register({
|
||||
email = '',
|
||||
username = '',
|
||||
password = '',
|
||||
rePassword = '',
|
||||
rulesAgreement = false,
|
||||
lang = '',
|
||||
captcha = '',
|
||||
}: RegisterParams): Promise<Resp<void>> {
|
||||
return request.post(
|
||||
'/api/signup',
|
||||
{ email, username, password, rePassword, rulesAgreement, lang, captcha },
|
||||
{ token: null },
|
||||
);
|
||||
}
|
||||
|
||||
resendActivation({ email = '', captcha }) {
|
||||
return request.post('/api/signup/repeat-message', { email, captcha });
|
||||
},
|
||||
};
|
||||
export function activate(key: string = ''): Promise<Resp<OAuthResponse>> {
|
||||
return request.post('/api/signup/confirm', { key }, { token: null });
|
||||
}
|
||||
|
||||
export function resendActivation(email: string = '', captcha: string = '') {
|
||||
return request.post('/api/signup/repeat-message', { email, captcha });
|
||||
}
|
||||
|
@ -2,11 +2,14 @@
|
||||
import { AuthContext } from 'app/services/authFlow';
|
||||
|
||||
export default class AbstractState {
|
||||
resolve(context: AuthContext, payload: { [key: string]: any }): any {}
|
||||
goBack(context: AuthContext): any {
|
||||
resolve(
|
||||
context: AuthContext,
|
||||
payload: Record<string, any>,
|
||||
): Promise<void> | void {}
|
||||
goBack(context: AuthContext): void {
|
||||
throw new Error('There is no way back');
|
||||
}
|
||||
reject(context: AuthContext, payload: { [key: string]: any }): any {}
|
||||
enter(context: AuthContext): any {}
|
||||
leave(context: AuthContext): any {}
|
||||
reject(context: AuthContext, payload?: Record<string, any>): void {}
|
||||
enter(context: AuthContext): Promise<void> | void {}
|
||||
leave(context: AuthContext): void {}
|
||||
}
|
||||
|
@ -1,12 +1,19 @@
|
||||
import AcceptRulesState from 'app/services/authFlow/AcceptRulesState';
|
||||
import CompleteState from 'app/services/authFlow/CompleteState';
|
||||
import { SinonMock } from 'sinon';
|
||||
|
||||
import { bootstrap, expectState, expectNavigate, expectRun } from './helpers';
|
||||
import {
|
||||
bootstrap,
|
||||
expectState,
|
||||
expectNavigate,
|
||||
expectRun,
|
||||
MockedAuthContext,
|
||||
} from './helpers';
|
||||
|
||||
describe('AcceptRulesState', () => {
|
||||
let state;
|
||||
let context;
|
||||
let mock;
|
||||
let state: AcceptRulesState;
|
||||
let context: MockedAuthContext;
|
||||
let mock: SinonMock;
|
||||
|
||||
beforeEach(() => {
|
||||
state = new AcceptRulesState();
|
||||
|
@ -1,10 +1,11 @@
|
||||
import logger from 'app/services/logger';
|
||||
|
||||
import AbstractState from './AbstractState';
|
||||
import { AuthContext } from './AuthFlow';
|
||||
import CompleteState from './CompleteState';
|
||||
|
||||
export default class AcceptRulesState extends AbstractState {
|
||||
enter(context) {
|
||||
enter(context: AuthContext): Promise<void> | void {
|
||||
const { user } = context.getState();
|
||||
|
||||
if (user.shouldAcceptRules) {
|
||||
@ -14,7 +15,7 @@ export default class AcceptRulesState extends AbstractState {
|
||||
}
|
||||
}
|
||||
|
||||
resolve(context) {
|
||||
resolve(context: AuthContext): Promise<void> | void {
|
||||
context
|
||||
.run('acceptRules')
|
||||
.then(() => context.setState(new CompleteState()))
|
||||
@ -23,7 +24,7 @@ export default class AcceptRulesState extends AbstractState {
|
||||
);
|
||||
}
|
||||
|
||||
reject(context) {
|
||||
reject(context: AuthContext): void {
|
||||
context.run('logout');
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,21 @@
|
||||
import sinon from 'sinon';
|
||||
import sinon, { SinonMock } from 'sinon';
|
||||
|
||||
import ActivationState from 'app/services/authFlow/ActivationState';
|
||||
import CompleteState from 'app/services/authFlow/CompleteState';
|
||||
import ResendActivationState from 'app/services/authFlow/ResendActivationState';
|
||||
|
||||
import { bootstrap, expectState, expectNavigate, expectRun } from './helpers';
|
||||
import {
|
||||
bootstrap,
|
||||
expectState,
|
||||
expectNavigate,
|
||||
expectRun,
|
||||
MockedAuthContext,
|
||||
} from './helpers';
|
||||
|
||||
describe('ActivationState', () => {
|
||||
let state;
|
||||
let context;
|
||||
let mock;
|
||||
let state: ActivationState;
|
||||
let context: MockedAuthContext;
|
||||
let mock: SinonMock;
|
||||
|
||||
beforeEach(() => {
|
||||
state = new ActivationState();
|
||||
@ -72,7 +78,7 @@ describe('ActivationState', () => {
|
||||
mock.expects('run').returns(promise);
|
||||
expectState(mock, CompleteState);
|
||||
|
||||
state.resolve(context);
|
||||
state.resolve(context, {});
|
||||
|
||||
return promise;
|
||||
});
|
||||
@ -83,7 +89,7 @@ describe('ActivationState', () => {
|
||||
mock.expects('run').returns(promise);
|
||||
mock.expects('setState').never();
|
||||
|
||||
state.resolve(context);
|
||||
state.resolve(context, {});
|
||||
|
||||
return promise.catch(mock.verify.bind(mock));
|
||||
});
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user