Change prettier rules

This commit is contained in:
ErickSkrauch 2020-05-24 02:08:24 +03:00
parent 73f0c37a6a
commit f85b9d8d35
382 changed files with 24137 additions and 26046 deletions

View File

@ -1,174 +1,168 @@
module.exports = {
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
},
extends: [
'eslint:recommended',
'plugin:jsdoc/recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'prettier/@typescript-eslint',
'plugin:prettier/recommended',
],
plugins: ['react'],
env: {
browser: true,
es6: true,
commonjs: true,
},
overrides: [
{
files: ['packages/webpack-utils/**', 'packages/scripts/**', 'jest/**'],
env: {
node: true,
},
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2018,
sourceType: 'module',
},
{
files: ['*.test.js'],
env: {
jest: true,
},
extends: [
'eslint:recommended',
'plugin:jsdoc/recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'prettier/@typescript-eslint',
'plugin:prettier/recommended',
],
plugins: ['react'],
env: {
browser: true,
es6: true,
commonjs: true,
},
{
files: ['tests-e2e/**'],
env: {
mocha: true,
},
globals: {
cy: 'readonly',
Cypress: 'readonly',
},
rules: {
'no-restricted-globals': 'off',
},
overrides: [
{
files: ['packages/webpack-utils/**', 'packages/scripts/**', 'jest/**'],
env: {
node: true,
},
},
{
files: ['*.test.js'],
env: {
jest: true,
},
},
{
files: ['tests-e2e/**'],
env: {
mocha: true,
},
globals: {
cy: 'readonly',
Cypress: 'readonly',
},
rules: {
'no-restricted-globals': 'off',
},
},
],
settings: {
react: {
version: 'detect',
},
},
],
settings: {
react: {
version: 'detect',
// @see: http://eslint.org/docs/rules/
rules: {
'no-prototype-builtins': 'warn', // temporary set to warn
'no-restricted-globals': [
'error',
'localStorage',
'sessionStorage', // we have our own localStorage module
'event',
],
'id-length': ['error', { min: 2, exceptions: ['x', 'y', 'i', 'k', 'l', 'm', 'n', '$', '_'] }],
'guard-for-in': ['error'],
'no-var': ['error'],
'prefer-const': ['error'],
'prefer-template': ['error'],
'no-template-curly-in-string': ['error'],
'no-multi-assign': ['error'],
eqeqeq: ['error'],
'prefer-rest-params': ['error'],
'prefer-object-spread': 'warn',
'prefer-destructuring': 'warn',
'no-bitwise': 'warn',
'no-negated-condition': 'warn',
'no-nested-ternary': 'warn',
'no-unneeded-ternary': 'warn',
'no-shadow': 'warn',
'no-else-return': 'warn',
radix: 'warn',
'prefer-promise-reject-errors': 'warn',
'object-shorthand': 'warn',
'require-atomic-updates': 'off',
// force extra lines around if, else, for, while, switch, return etc
'padding-line-between-statements': [
'error',
{
blankLine: 'always',
prev: '*',
next: ['if', 'for', 'while', 'switch', 'return'],
},
{
blankLine: 'always',
prev: ['if', 'for', 'while', 'switch', 'return'],
next: '*',
},
{
blankLine: 'never',
prev: ['if', 'for', 'while', 'switch', 'return'],
next: 'break',
},
],
'jsdoc/require-param-description': 'off',
'jsdoc/require-returns-description': 'off',
'jsdoc/require-jsdoc': 'off',
'jsdoc/valid-types': 'off',
'jsdoc/no-undefined-types': 'off',
'jsdoc/require-returns': 'off',
// react
'react/display-name': 'off',
'react/react-in-jsx-scope': 'warn',
'react/forbid-prop-types': 'warn',
'react/jsx-boolean-value': 'warn',
'react/jsx-closing-bracket-location': 'off', // can not configure for our code style
'react/jsx-curly-spacing': 'warn',
'react/jsx-handler-names': ['warn', { eventHandlerPrefix: 'on', eventHandlerPropPrefix: 'on' }],
'react/jsx-key': 'warn',
'react/jsx-max-props-per-line': 'off',
'react/jsx-no-bind': 'off',
'react/jsx-no-duplicate-props': 'warn',
'react/jsx-no-literals': 'off',
'react/jsx-no-undef': 'error',
'react/jsx-pascal-case': 'warn',
'react/jsx-uses-react': 'warn',
'react/jsx-uses-vars': 'warn',
'react/jsx-no-comment-textnodes': 'warn',
'react/jsx-wrap-multilines': 'warn',
'react/no-deprecated': 'error',
'react/no-did-mount-set-state': 'warn',
'react/no-did-update-set-state': 'warn',
'react/no-direct-mutation-state': 'warn',
'react/require-render-return': 'warn',
'react/no-is-mounted': 'warn',
'react/no-multi-comp': 'off',
'react/no-string-refs': 'warn',
'react/no-unknown-property': 'warn',
'react/prefer-es6-class': 'warn',
'react/prop-types': 'off', // using ts for this task
'react/self-closing-comp': 'warn',
'react/sort-comp': 'off',
// ts
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/ban-ts-ignore': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
vars: 'all',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
},
// @see: http://eslint.org/docs/rules/
rules: {
'no-prototype-builtins': 'warn', // temporary set to warn
'no-restricted-globals': [
'error',
'localStorage',
'sessionStorage', // we have our own localStorage module
'event',
],
'id-length': [
'error',
{ min: 2, exceptions: ['x', 'y', 'i', 'k', 'l', 'm', 'n', '$', '_'] },
],
'guard-for-in': ['error'],
'no-var': ['error'],
'prefer-const': ['error'],
'prefer-template': ['error'],
'no-template-curly-in-string': ['error'],
'no-multi-assign': ['error'],
eqeqeq: ['error'],
'prefer-rest-params': ['error'],
'prefer-object-spread': 'warn',
'prefer-destructuring': 'warn',
'no-bitwise': 'warn',
'no-negated-condition': 'warn',
'no-nested-ternary': 'warn',
'no-unneeded-ternary': 'warn',
'no-shadow': 'warn',
'no-else-return': 'warn',
radix: 'warn',
'prefer-promise-reject-errors': 'warn',
'object-shorthand': 'warn',
'require-atomic-updates': 'off',
// force extra lines around if, else, for, while, switch, return etc
'padding-line-between-statements': [
'error',
{
blankLine: 'always',
prev: '*',
next: ['if', 'for', 'while', 'switch', 'return'],
},
{
blankLine: 'always',
prev: ['if', 'for', 'while', 'switch', 'return'],
next: '*',
},
{
blankLine: 'never',
prev: ['if', 'for', 'while', 'switch', 'return'],
next: 'break',
},
],
'jsdoc/require-param-description': 'off',
'jsdoc/require-returns-description': 'off',
'jsdoc/require-jsdoc': 'off',
'jsdoc/valid-types': 'off',
'jsdoc/no-undefined-types': 'off',
'jsdoc/require-returns': 'off',
// react
'react/display-name': 'off',
'react/react-in-jsx-scope': 'warn',
'react/forbid-prop-types': 'warn',
'react/jsx-boolean-value': 'warn',
'react/jsx-closing-bracket-location': 'off', // can not configure for our code style
'react/jsx-curly-spacing': 'warn',
'react/jsx-handler-names': [
'warn',
{ eventHandlerPrefix: 'on', eventHandlerPropPrefix: 'on' },
],
'react/jsx-key': 'warn',
'react/jsx-max-props-per-line': 'off',
'react/jsx-no-bind': 'off',
'react/jsx-no-duplicate-props': 'warn',
'react/jsx-no-literals': 'off',
'react/jsx-no-undef': 'error',
'react/jsx-pascal-case': 'warn',
'react/jsx-uses-react': 'warn',
'react/jsx-uses-vars': 'warn',
'react/jsx-no-comment-textnodes': 'warn',
'react/jsx-wrap-multilines': 'warn',
'react/no-deprecated': 'error',
'react/no-did-mount-set-state': 'warn',
'react/no-did-update-set-state': 'warn',
'react/no-direct-mutation-state': 'warn',
'react/require-render-return': 'warn',
'react/no-is-mounted': 'warn',
'react/no-multi-comp': 'off',
'react/no-string-refs': 'warn',
'react/no-unknown-property': 'warn',
'react/prefer-es6-class': 'warn',
'react/prop-types': 'off', // using ts for this task
'react/self-closing-comp': 'warn',
'react/sort-comp': 'off',
// ts
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-use-before-define': 'off',
'@typescript-eslint/ban-ts-ignore': 'off',
'@typescript-eslint/ban-ts-comment': 'off',
'@typescript-eslint/ban-types': 'off',
'@typescript-eslint/no-empty-function': 'off',
'@typescript-eslint/no-inferrable-types': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{
vars: 'all',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
};

View File

@ -1,6 +1,8 @@
{
"trailingComma": "all",
"singleQuote": true,
"proseWrap": "always",
"endOfLine": "lf"
"trailingComma": "all",
"singleQuote": true,
"proseWrap": "always",
"endOfLine": "lf",
"tabWidth": 4,
"printWidth": 120
}

View File

@ -5,7 +5,7 @@ import storyDecorator from './storyDecorator';
const req = require.context('../packages/app', true, /\.story\.[tj]sx?$/);
function loadStories() {
req.keys().forEach((filename) => req(filename));
req.keys().forEach((filename) => req(filename));
}
addDecorator(storyDecorator);

View File

@ -2,40 +2,37 @@ import React from 'react';
import { useDispatch } from 'react-redux';
import { Channel } from '@storybook/channels';
import { setIntlConfig } from 'storybook-addon-intl';
import {
EVENT_SET_LOCALE_ID,
EVENT_GET_LOCALE_ID,
} from 'storybook-addon-intl/dist/shared';
import { EVENT_SET_LOCALE_ID, EVENT_GET_LOCALE_ID } from 'storybook-addon-intl/dist/shared';
import { SUPPORTED_LANGUAGES, DEFAULT_LANGUAGE } from 'app/services/i18n';
import { setLocale } from 'app/components/i18n/actions';
setIntlConfig({
locales: SUPPORTED_LANGUAGES,
defaultLocale: DEFAULT_LANGUAGE,
locales: SUPPORTED_LANGUAGES,
defaultLocale: DEFAULT_LANGUAGE,
});
const IntlDecorator: React.ComponentType<{
channel: Channel;
channel: Channel;
}> = ({ channel, children }) => {
const dispatch = useDispatch();
const dispatch = useDispatch();
React.useEffect(() => {
const onLocaleChange = (locale: string) => {
dispatch(setLocale(locale));
};
React.useEffect(() => {
const onLocaleChange = (locale: string) => {
dispatch(setLocale(locale));
};
// Listen for change of locale
channel.on(EVENT_SET_LOCALE_ID, onLocaleChange);
// Listen for change of locale
channel.on(EVENT_SET_LOCALE_ID, onLocaleChange);
// Request the current locale
channel.emit(EVENT_GET_LOCALE_ID);
// Request the current locale
channel.emit(EVENT_GET_LOCALE_ID);
return () => {
channel.removeListener(EVENT_SET_LOCALE_ID, onLocaleChange);
};
}, [channel]);
return () => {
channel.removeListener(EVENT_SET_LOCALE_ID, onLocaleChange);
};
}, [channel]);
return children as React.ReactElement;
return children as React.ReactElement;
};
export default IntlDecorator;

View File

@ -10,11 +10,11 @@ import { IntlDecorator } from './decorators';
const store = storeFactory();
export default ((story) => {
const channel = addons.getChannel();
const channel = addons.getChannel();
return (
<ContextProvider store={store} history={browserHistory}>
<IntlDecorator channel={channel}>{story()}</IntlDecorator>
</ContextProvider>
);
return (
<ContextProvider store={store} history={browserHistory}>
<IntlDecorator channel={channel}>{story()}</IntlDecorator>
</ContextProvider>
);
}) as DecoratorFunction<React.ReactElement>;

View File

@ -1,14 +1,14 @@
const rootConfig = require('../webpack.config');
module.exports = async ({ config }) => ({
...config,
resolve: rootConfig.resolve,
module: {
...config.module,
// our rules should satisfy all storybook needs,
// so replace all storybook defaults with our rules
rules: rootConfig.module.rules,
},
...config,
resolve: rootConfig.resolve,
module: {
...config.module,
// our rules should satisfy all storybook needs,
// so replace all storybook defaults with our rules
rules: rootConfig.module.rules,
},
resolveLoader: rootConfig.resolveLoader,
resolveLoader: rootConfig.resolveLoader,
});

View File

@ -1,3 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
"typescript.tsdk": "node_modules/typescript/lib"
}

392
@types/chalk.d.ts vendored
View File

@ -4,141 +4,141 @@
*/
declare module 'chalk' {
const enum LevelEnum {
/**
const enum LevelEnum {
/**
All colors disabled.
*/
None = 0,
None = 0,
/**
/**
Basic 16 colors support.
*/
Basic = 1,
Basic = 1,
/**
/**
ANSI 256 colors support.
*/
Ansi256 = 2,
Ansi256 = 2,
/**
/**
Truecolor 16 million colors support.
*/
TrueColor = 3,
}
TrueColor = 3,
}
/**
/**
Basic foreground colors.
[More colors here.](https://github.com/chalk/chalk/blob/master/readme.md#256-and-truecolor-color-support)
*/
type ForegroundColor =
| 'black'
| 'red'
| 'green'
| 'yellow'
| 'blue'
| 'magenta'
| 'cyan'
| 'white'
| 'gray'
| 'grey'
| 'blackBright'
| 'redBright'
| 'greenBright'
| 'yellowBright'
| 'blueBright'
| 'magentaBright'
| 'cyanBright'
| 'whiteBright';
type ForegroundColor =
| 'black'
| 'red'
| 'green'
| 'yellow'
| 'blue'
| 'magenta'
| 'cyan'
| 'white'
| 'gray'
| 'grey'
| 'blackBright'
| 'redBright'
| 'greenBright'
| 'yellowBright'
| 'blueBright'
| 'magentaBright'
| 'cyanBright'
| 'whiteBright';
/**
/**
Basic background colors.
[More colors here.](https://github.com/chalk/chalk/blob/master/readme.md#256-and-truecolor-color-support)
*/
type BackgroundColor =
| 'bgBlack'
| 'bgRed'
| 'bgGreen'
| 'bgYellow'
| 'bgBlue'
| 'bgMagenta'
| 'bgCyan'
| 'bgWhite'
| 'bgGray'
| 'bgGrey'
| 'bgBlackBright'
| 'bgRedBright'
| 'bgGreenBright'
| 'bgYellowBright'
| 'bgBlueBright'
| 'bgMagentaBright'
| 'bgCyanBright'
| 'bgWhiteBright';
type BackgroundColor =
| 'bgBlack'
| 'bgRed'
| 'bgGreen'
| 'bgYellow'
| 'bgBlue'
| 'bgMagenta'
| 'bgCyan'
| 'bgWhite'
| 'bgGray'
| 'bgGrey'
| 'bgBlackBright'
| 'bgRedBright'
| 'bgGreenBright'
| 'bgYellowBright'
| 'bgBlueBright'
| 'bgMagentaBright'
| 'bgCyanBright'
| 'bgWhiteBright';
/**
/**
Basic colors.
[More colors here.](https://github.com/chalk/chalk/blob/master/readme.md#256-and-truecolor-color-support)
*/
type Color = ForegroundColor | BackgroundColor;
type Color = ForegroundColor | BackgroundColor;
type Modifiers =
| 'reset'
| 'bold'
| 'dim'
| 'italic'
| 'underline'
| 'inverse'
| 'hidden'
| 'strikethrough'
| 'visible';
type Modifiers =
| 'reset'
| 'bold'
| 'dim'
| 'italic'
| 'underline'
| 'inverse'
| 'hidden'
| 'strikethrough'
| 'visible';
namespace chalk {
type Level = LevelEnum;
namespace chalk {
type Level = LevelEnum;
interface Options {
/**
interface Options {
/**
Specify the color support for Chalk.
By default, color support is automatically detected based on the environment.
*/
level?: Level;
}
level?: Level;
}
interface Instance {
/**
interface Instance {
/**
Return a new Chalk instance.
*/
new (options?: Options): Chalk;
}
new (options?: Options): Chalk;
}
/**
/**
Detect whether the terminal supports color.
*/
interface ColorSupport {
/**
interface ColorSupport {
/**
The color level used by Chalk.
*/
level: Level;
level: Level;
/**
/**
Return whether Chalk supports basic 16 colors.
*/
hasBasic: boolean;
hasBasic: boolean;
/**
/**
Return whether Chalk supports ANSI 256 colors.
*/
has256: boolean;
has256: boolean;
/**
/**
Return whether Chalk supports Truecolor 16 million colors.
*/
has16m: boolean;
}
has16m: boolean;
}
interface ChalkFunction {
/**
interface ChalkFunction {
/**
Use a template string.
@remarks Template literals are unsupported for nested calls (see [issue #341](https://github.com/chalk/chalk/issues/341))
@ -154,24 +154,24 @@ declare module 'chalk' {
`);
```
*/
(text: TemplateStringsArray, ...placeholders: unknown[]): string;
(text: TemplateStringsArray, ...placeholders: unknown[]): string;
(...text: unknown[]): string;
}
(...text: unknown[]): string;
}
interface Chalk extends ChalkFunction {
/**
interface Chalk extends ChalkFunction {
/**
Return a new Chalk instance.
*/
Instance: Instance;
Instance: Instance;
/**
/**
The color support for Chalk.
By default, color support is automatically detected based on the environment.
*/
level: Level;
level: Level;
/**
/**
Use HEX value to set text color.
@param color - Hexadecimal value representing the desired color.
@ -183,9 +183,9 @@ declare module 'chalk' {
chalk.hex('#DEADED');
```
*/
hex(color: string): Chalk;
hex(color: string): Chalk;
/**
/**
Use keyword color value to set text color.
@param color - Keyword value representing the desired color.
@ -197,42 +197,42 @@ declare module 'chalk' {
chalk.keyword('orange');
```
*/
keyword(color: string): Chalk;
keyword(color: string): Chalk;
/**
/**
Use RGB values to set text color.
*/
rgb(red: number, green: number, blue: number): Chalk;
rgb(red: number, green: number, blue: number): Chalk;
/**
/**
Use HSL values to set text color.
*/
hsl(hue: number, saturation: number, lightness: number): Chalk;
hsl(hue: number, saturation: number, lightness: number): Chalk;
/**
/**
Use HSV values to set text color.
*/
hsv(hue: number, saturation: number, value: number): Chalk;
hsv(hue: number, saturation: number, value: number): Chalk;
/**
/**
Use HWB values to set text color.
*/
hwb(hue: number, whiteness: number, blackness: number): Chalk;
hwb(hue: number, whiteness: number, blackness: number): Chalk;
/**
/**
Use a [Select/Set Graphic Rendition](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters) (SGR) [color code number](https://en.wikipedia.org/wiki/ANSI_escape_code#3/4_bit) to set text color.
30 <= code && code < 38 || 90 <= code && code < 98
For example, 31 for red, 91 for redBright.
*/
ansi(code: number): Chalk;
ansi(code: number): Chalk;
/**
/**
Use a [8-bit unsigned number](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) to set text color.
*/
ansi256(index: number): Chalk;
ansi256(index: number): Chalk;
/**
/**
Use HEX value to set background color.
@param color - Hexadecimal value representing the desired color.
@ -244,9 +244,9 @@ declare module 'chalk' {
chalk.bgHex('#DEADED');
```
*/
bgHex(color: string): Chalk;
bgHex(color: string): Chalk;
/**
/**
Use keyword color value to set background color.
@param color - Keyword value representing the desired color.
@ -258,162 +258,162 @@ declare module 'chalk' {
chalk.bgKeyword('orange');
```
*/
bgKeyword(color: string): Chalk;
bgKeyword(color: string): Chalk;
/**
/**
Use RGB values to set background color.
*/
bgRgb(red: number, green: number, blue: number): Chalk;
bgRgb(red: number, green: number, blue: number): Chalk;
/**
/**
Use HSL values to set background color.
*/
bgHsl(hue: number, saturation: number, lightness: number): Chalk;
bgHsl(hue: number, saturation: number, lightness: number): Chalk;
/**
/**
Use HSV values to set background color.
*/
bgHsv(hue: number, saturation: number, value: number): Chalk;
bgHsv(hue: number, saturation: number, value: number): Chalk;
/**
/**
Use HWB values to set background color.
*/
bgHwb(hue: number, whiteness: number, blackness: number): Chalk;
bgHwb(hue: number, whiteness: number, blackness: number): Chalk;
/**
/**
Use a [Select/Set Graphic Rendition](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters) (SGR) [color code number](https://en.wikipedia.org/wiki/ANSI_escape_code#3/4_bit) to set background color.
30 <= code && code < 38 || 90 <= code && code < 98
For example, 31 for red, 91 for redBright.
Use the foreground code, not the background code (for example, not 41, nor 101).
*/
bgAnsi(code: number): Chalk;
bgAnsi(code: number): Chalk;
/**
/**
Use a [8-bit unsigned number](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) to set background color.
*/
bgAnsi256(index: number): Chalk;
bgAnsi256(index: number): Chalk;
/**
/**
Modifier: Resets the current color chain.
*/
readonly reset: Chalk;
readonly reset: Chalk;
/**
/**
Modifier: Make text bold.
*/
readonly bold: Chalk;
readonly bold: Chalk;
/**
/**
Modifier: Emitting only a small amount of light.
*/
readonly dim: Chalk;
readonly dim: Chalk;
/**
/**
Modifier: Make text italic. (Not widely supported)
*/
readonly italic: Chalk;
readonly italic: Chalk;
/**
/**
Modifier: Make text underline. (Not widely supported)
*/
readonly underline: Chalk;
readonly underline: Chalk;
/**
/**
Modifier: Inverse background and foreground colors.
*/
readonly inverse: Chalk;
readonly inverse: Chalk;
/**
/**
Modifier: Prints the text, but makes it invisible.
*/
readonly hidden: Chalk;
readonly hidden: Chalk;
/**
/**
Modifier: Puts a horizontal line through the center of the text. (Not widely supported)
*/
readonly strikethrough: Chalk;
readonly strikethrough: Chalk;
/**
/**
Modifier: Prints the text only when Chalk has a color support level > 0.
Can be useful for things that are purely cosmetic.
*/
readonly visible: Chalk;
readonly visible: Chalk;
readonly black: Chalk;
readonly red: Chalk;
readonly green: Chalk;
readonly yellow: Chalk;
readonly blue: Chalk;
readonly magenta: Chalk;
readonly cyan: Chalk;
readonly white: Chalk;
readonly black: Chalk;
readonly red: Chalk;
readonly green: Chalk;
readonly yellow: Chalk;
readonly blue: Chalk;
readonly magenta: Chalk;
readonly cyan: Chalk;
readonly white: Chalk;
/*
/*
Alias for `blackBright`.
*/
readonly gray: Chalk;
readonly gray: Chalk;
/*
/*
Alias for `blackBright`.
*/
readonly grey: Chalk;
readonly grey: Chalk;
readonly blackBright: Chalk;
readonly redBright: Chalk;
readonly greenBright: Chalk;
readonly yellowBright: Chalk;
readonly blueBright: Chalk;
readonly magentaBright: Chalk;
readonly cyanBright: Chalk;
readonly whiteBright: Chalk;
readonly blackBright: Chalk;
readonly redBright: Chalk;
readonly greenBright: Chalk;
readonly yellowBright: Chalk;
readonly blueBright: Chalk;
readonly magentaBright: Chalk;
readonly cyanBright: Chalk;
readonly whiteBright: Chalk;
readonly bgBlack: Chalk;
readonly bgRed: Chalk;
readonly bgGreen: Chalk;
readonly bgYellow: Chalk;
readonly bgBlue: Chalk;
readonly bgMagenta: Chalk;
readonly bgCyan: Chalk;
readonly bgWhite: Chalk;
readonly bgBlack: Chalk;
readonly bgRed: Chalk;
readonly bgGreen: Chalk;
readonly bgYellow: Chalk;
readonly bgBlue: Chalk;
readonly bgMagenta: Chalk;
readonly bgCyan: Chalk;
readonly bgWhite: Chalk;
/*
/*
Alias for `bgBlackBright`.
*/
readonly bgGray: Chalk;
readonly bgGray: Chalk;
/*
/*
Alias for `bgBlackBright`.
*/
readonly bgGrey: Chalk;
readonly bgGrey: Chalk;
readonly bgBlackBright: Chalk;
readonly bgRedBright: Chalk;
readonly bgGreenBright: Chalk;
readonly bgYellowBright: Chalk;
readonly bgBlueBright: Chalk;
readonly bgMagentaBright: Chalk;
readonly bgCyanBright: Chalk;
readonly bgWhiteBright: Chalk;
readonly bgBlackBright: Chalk;
readonly bgRedBright: Chalk;
readonly bgGreenBright: Chalk;
readonly bgYellowBright: Chalk;
readonly bgBlueBright: Chalk;
readonly bgMagentaBright: Chalk;
readonly bgCyanBright: Chalk;
readonly bgWhiteBright: Chalk;
}
}
}
/**
/**
Main Chalk object that allows to chain styles together.
Call the last one as a method with a string argument.
Order doesn't matter, and later styles take precedent in case of a conflict.
This simply means that `chalk.red.yellow.green` is equivalent to `chalk.green`.
*/
const chalk: chalk.Chalk &
chalk.ChalkFunction & {
supportsColor: chalk.ColorSupport | false;
Level: LevelEnum;
Color: Color;
ForegroundColor: ForegroundColor;
BackgroundColor: BackgroundColor;
Modifiers: Modifiers;
stderr: chalk.Chalk & { supportsColor: chalk.ColorSupport | false };
};
const chalk: chalk.Chalk &
chalk.ChalkFunction & {
supportsColor: chalk.ColorSupport | false;
Level: LevelEnum;
Color: Color;
ForegroundColor: ForegroundColor;
BackgroundColor: BackgroundColor;
Modifiers: Modifiers;
stderr: chalk.Chalk & { supportsColor: chalk.ColorSupport | false };
};
export = chalk;
export = chalk;
}

View File

@ -1,102 +1,98 @@
declare module 'crowdin-api' {
export interface ProjectInfoFile {
node_type: 'file';
id: number;
name: string;
created: string;
last_updated: string;
last_accessed: string;
last_revision: string;
}
export interface ProjectInfoDirectory {
node_type: 'directory';
id: number;
name: string;
files: Array<ProjectInfoFile | ProjectInfoDirectory>;
}
export interface ProjectInfoResponse {
details: {
source_language: {
export interface ProjectInfoFile {
node_type: 'file';
id: number;
name: string;
code: string;
};
name: string;
identifier: string;
created: string;
description: string;
join_policy: string;
last_build: string | null;
last_activity: string;
participants_count: string; // it's number, but string in the response
logo_url: string | null;
total_strings_count: string; // it's number, but string in the response
total_words_count: string; // it's number, but string in the response
duplicate_strings_count: number;
duplicate_words_count: number;
invite_url: {
translator: string;
proofreader: string;
};
};
languages: Array<{
name: string; // English language name
code: string;
can_translate: 0 | 1;
can_approve: 0 | 1;
}>;
files: Array<ProjectInfoFile | ProjectInfoDirectory>;
}
created: string;
last_updated: string;
last_accessed: string;
last_revision: string;
}
export interface LanguageStatusNode {
node_type: 'directory' | 'file';
id: number;
name: string;
phrases: number;
translated: number;
approved: number;
words: number;
words_translated: number;
words_approved: number;
files: Array<LanguageStatusNode>;
}
export interface ProjectInfoDirectory {
node_type: 'directory';
id: number;
name: string;
files: Array<ProjectInfoFile | ProjectInfoDirectory>;
}
export interface LanguageStatusResponse {
files: Array<LanguageStatusNode>;
}
export interface ProjectInfoResponse {
details: {
source_language: {
name: string;
code: string;
};
name: string;
identifier: string;
created: string;
description: string;
join_policy: string;
last_build: string | null;
last_activity: string;
participants_count: string; // it's number, but string in the response
logo_url: string | null;
total_strings_count: string; // it's number, but string in the response
total_words_count: string; // it's number, but string in the response
duplicate_strings_count: number;
duplicate_words_count: number;
invite_url: {
translator: string;
proofreader: string;
};
};
languages: Array<{
name: string; // English language name
code: string;
can_translate: 0 | 1;
can_approve: 0 | 1;
}>;
files: Array<ProjectInfoFile | ProjectInfoDirectory>;
}
type FilesList = Record<string, string | ReadableStream>;
export interface LanguageStatusNode {
node_type: 'directory' | 'file';
id: number;
name: string;
phrases: number;
translated: number;
approved: number;
words: number;
words_translated: number;
words_approved: number;
files: Array<LanguageStatusNode>;
}
export default class CrowdinApi {
constructor(params: {
apiKey: string;
projectName: string;
baseUrl?: string;
});
projectInfo(): Promise<ProjectInfoResponse>;
languageStatus(language: string): Promise<LanguageStatusResponse>;
exportFile(
file: string,
language: string,
params?: {
branch?: string;
format?: 'xliff';
export_translated_only?: boolean;
export_approved_only?: boolean;
},
): Promise<string>; // TODO: not sure about Promise return type
updateFile(
files: FilesList,
params: {
titles?: Record<string, string>;
export_patterns?: Record<string, string>;
new_names?: Record<string, string>;
first_line_contains_header?: string;
scheme?: string;
update_option?: 'update_as_unapproved' | 'update_without_changes';
branch?: string;
},
): Promise<void>;
}
export interface LanguageStatusResponse {
files: Array<LanguageStatusNode>;
}
type FilesList = Record<string, string | ReadableStream>;
export default class CrowdinApi {
constructor(params: { apiKey: string; projectName: string; baseUrl?: string });
projectInfo(): Promise<ProjectInfoResponse>;
languageStatus(language: string): Promise<LanguageStatusResponse>;
exportFile(
file: string,
language: string,
params?: {
branch?: string;
format?: 'xliff';
export_translated_only?: boolean;
export_approved_only?: boolean;
},
): Promise<string>; // TODO: not sure about Promise return type
updateFile(
files: FilesList,
params: {
titles?: Record<string, string>;
export_patterns?: Record<string, string>;
new_names?: Record<string, string>;
first_line_contains_header?: string;
scheme?: string;
update_option?: 'update_as_unapproved' | 'update_without_changes';
branch?: string;
},
): Promise<void>;
}
}

View File

@ -1,13 +1,10 @@
declare module 'multi-progress' {
export default class MultiProgress {
constructor(stream?: string);
newBar(
schema: string,
options: ProgressBar.ProgressBarOptions,
): ProgressBar;
terminate(): void;
move(index: number): void;
tick(index: number, value?: number, options?: any): void;
update(index: number, value?: number, options?: any): void;
}
export default class MultiProgress {
constructor(stream?: string);
newBar(schema: string, options: ProgressBar.ProgressBarOptions): ProgressBar;
terminate(): void;
move(index: number): void;
tick(index: number, value?: number, options?: any): void;
update(index: number, value?: number, options?: any): void;
}
}

106
@types/prompt.d.ts vendored
View File

@ -2,65 +2,55 @@
// Project: https://github.com/flatiron/prompt
declare module 'prompt' {
type PropertiesType =
| Array<string>
| prompt.PromptSchema
| Array<prompt.PromptPropertyOptions>;
type PropertiesType = Array<string> | prompt.PromptSchema | Array<prompt.PromptPropertyOptions>;
namespace prompt {
interface PromptSchema {
properties: PromptProperties;
namespace prompt {
interface PromptSchema {
properties: PromptProperties;
}
interface PromptProperties {
[propName: string]: PromptPropertyOptions;
}
interface PromptPropertyOptions {
name?: string;
pattern?: RegExp;
message?: string;
required?: boolean;
hidden?: boolean;
description?: string;
type?: string;
default?: string;
before?: (value: any) => any;
conform?: (result: any) => boolean;
}
export function start(): void;
export function get<T extends PropertiesType>(
properties: T,
callback: (
err: Error,
result: T extends Array<string>
? Record<T[number], string>
: T extends PromptSchema
? Record<keyof T['properties'], string>
: T extends Array<PromptPropertyOptions>
? Record<T[number]['name'] extends string ? T[number]['name'] : number, string>
: never,
) => void,
): void;
export function addProperties(obj: any, properties: PropertiesType, callback: (err: Error) => void): void;
export function history(propertyName: string): any;
export let override: any;
export let colors: boolean;
export let message: string;
export let delimiter: string;
}
interface PromptProperties {
[propName: string]: PromptPropertyOptions;
}
interface PromptPropertyOptions {
name?: string;
pattern?: RegExp;
message?: string;
required?: boolean;
hidden?: boolean;
description?: string;
type?: string;
default?: string;
before?: (value: any) => any;
conform?: (result: any) => boolean;
}
export function start(): void;
export function get<T extends PropertiesType>(
properties: T,
callback: (
err: Error,
result: T extends Array<string>
? Record<T[number], string>
: T extends PromptSchema
? Record<keyof T['properties'], string>
: T extends Array<PromptPropertyOptions>
? Record<
T[number]['name'] extends string ? T[number]['name'] : number,
string
>
: never,
) => void,
): void;
export function addProperties(
obj: any,
properties: PropertiesType,
callback: (err: Error) => void,
): void;
export function history(propertyName: string): any;
export let override: any;
export let colors: boolean;
export let message: string;
export let delimiter: string;
}
export = prompt;
export = prompt;
}

View File

@ -3,23 +3,13 @@
// 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 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;
export default function persistState(paths: string | string[], config: ConfigRS): () => any;
}

153
@types/unexpected.d.ts vendored
View File

@ -1,86 +1,77 @@
declare module 'unexpected' {
namespace unexpected {
interface EnchantedPromise<T> extends Promise<T> {
and<A extends Array<unknown> = []>(
assertionName: string,
subject: unknown,
...args: A
): EnchantedPromise<any>;
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;
}
}
interface Expect {
/**
* @see http://unexpected.js.org/api/expect/
*/
<A extends Array<unknown> = []>(
subject: unknown,
assertionName: string,
...args: A
): EnchantedPromise<any>;
const unexpected: unexpected.Expect;
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;
export = unexpected;
}

View File

@ -1,59 +1,59 @@
declare module '*.html' {
const url: string;
export = url;
const url: string;
export = url;
}
declare module '*.svg' {
const url: string;
export = url;
const url: string;
export = url;
}
declare module '*.png' {
const url: string;
export = url;
const url: string;
export = url;
}
declare module '*.gif' {
const url: string;
export = url;
const url: string;
export = url;
}
declare module '*.jpg' {
const url: string;
export = url;
const url: string;
export = url;
}
declare module '*.intl.json' {
import { MessageDescriptor } from 'react-intl';
import { MessageDescriptor } from 'react-intl';
const descriptor: Record<string, MessageDescriptor>;
const descriptor: Record<string, MessageDescriptor>;
export = descriptor;
export = descriptor;
}
declare module '*.json' {
const jsonContents: {
[key: string]: any;
};
const jsonContents: {
[key: string]: any;
};
export = jsonContents;
export = jsonContents;
}
declare module '*.scss' {
// TODO: replace with:
// https://www.npmjs.com/package/css-modules-typescript-loader
// https://github.com/Jimdo/typings-for-css-modules-loader
const classNames: {
[className: string]: string;
};
// TODO: replace with:
// https://www.npmjs.com/package/css-modules-typescript-loader
// https://github.com/Jimdo/typings-for-css-modules-loader
const classNames: {
[className: string]: string;
};
export = classNames;
export = classNames;
}
declare module '*.css' {
const classNames: {
[className: string]: string;
};
const classNames: {
[className: string]: string;
};
export = classNames;
export = classNames;
}

View File

@ -3,8 +3,7 @@
[![Build Status](https://travis-ci.org/elyby/accounts-frontend.svg?branch=master)](https://travis-ci.org/elyby/accounts-frontend)
[![Ely.by translation on Crowdin](https://d322cqt584bo4o.cloudfront.net/elyby/localized.svg)](https://translate.ely.by/project/elyby)
Web interface for Ely.by Accounts service. Developed using ReactJS and Flow
typing.
Web interface for Ely.by Accounts service. Developed using ReactJS and Flow typing.
## Development
@ -19,16 +18,15 @@ cd accounts-frontend
yarn install
```
After that you need to copy `config/template.env.js` into `config/env.js` and
adjust it for yourself. Then you can start the application in dev mode:
After that you need to copy `config/template.env.js` into `config/env.js` and adjust it for yourself. Then you can start
the application in dev mode:
```bash
yarn start
```
This will start the dev server on port 8080, which will automatically apply all
changes in project files, as well as proxy all requests to the backend on the
domain specified in `env.js`.
This will start the dev server on port 8080, which will automatically apply all changes in project files, as well as
proxy all requests to the backend on the domain specified in `env.js`.
To run the tests execute:
@ -42,8 +40,7 @@ yarn test
2. Place your code in a separate branch `git checkout -b <your_branch_name>`.
3. Add your fork as a remote
`git remote add fork https://github.com/<your_username>/accounts-frontend.git`.
3. Add your fork as a remote `git remote add fork https://github.com/<your_username>/accounts-frontend.git`.
4. Push to your fork repository `git push -u fork <your_branch_name>`.
@ -52,5 +49,4 @@ yarn test
## Translating
Ely.by translation is done through the [Crowdin](https://crowdin.com) service.
[Click here](https://translate.ely.by/project/elyby/invite) to participate in
the translation of the project.
[Click here](https://translate.ely.by/project/elyby/invite) to participate in the translation of the project.

View File

@ -1,40 +1,40 @@
/* eslint-env node */
module.exports = {
presets: [
[
'@babel/preset-typescript',
{
allowDeclareFields: true,
},
],
'@babel/preset-react',
'@babel/preset-env',
],
plugins: [
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-proposal-function-bind',
'@babel/plugin-proposal-class-properties',
[
'@babel/plugin-transform-runtime',
{
corejs: 3,
},
],
['react-intl', { messagesDir: './build/messages/' }],
],
env: {
webpack: {
plugins: ['react-hot-loader/babel'],
presets: [
presets: [
[
'@babel/preset-env',
{
modules: false,
useBuiltIns: 'usage', // or "entry"
corejs: 3,
},
'@babel/preset-typescript',
{
allowDeclareFields: true,
},
],
],
'@babel/preset-react',
'@babel/preset-env',
],
plugins: [
'@babel/plugin-syntax-dynamic-import',
'@babel/plugin-proposal-function-bind',
'@babel/plugin-proposal-class-properties',
[
'@babel/plugin-transform-runtime',
{
corejs: 3,
},
],
['react-intl', { messagesDir: './build/messages/' }],
],
env: {
webpack: {
plugins: ['react-hot-loader/babel'],
presets: [
[
'@babel/preset-env',
{
modules: false,
useBuiltIns: 'usage', // or "entry"
corejs: 3,
},
],
],
},
},
},
};

View File

@ -5,9 +5,9 @@ require('dotenv').config();
const { env } = process;
module.exports = {
version: env.VERSION || env.NODE_ENV,
apiHost: env.API_HOST || 'https://dev.account.ely.by',
ga: env.GA_ID && { id: env.GA_ID },
sentryDSN: env.SENTRY_DSN,
crowdinApiKey: env.CROWDIN_API_KEY,
version: env.VERSION || env.NODE_ENV,
apiHost: env.API_HOST || 'https://dev.account.ely.by',
ga: env.GA_ID && { id: env.GA_ID },
sentryDSN: env.SENTRY_DSN,
crowdinApiKey: env.CROWDIN_API_KEY,
};

View File

@ -5,15 +5,15 @@ const path = require('path');
const { transform } = require('webpack-utils/intl-loader');
module.exports = {
/**
* @param {string} src - transformed module source code
* @param {string} filename - transformed module file path
* @param {{[key: string]: any}} config - jest config
* @param {{instrument: boolean}} options - additional options
*
* @returns {string}
*/
process(src, filename, config, options) {
return transform(src, filename, path.resolve(`${__dirname}/../../..`));
},
/**
* @param {string} src - transformed module source code
* @param {string} filename - transformed module file path
* @param {{[key: string]: any}} config - jest config
* @param {{instrument: boolean}} options - additional options
*
* @returns {string}
*/
process(src, filename, config, options) {
return transform(src, filename, path.resolve(`${__dirname}/../../..`));
},
};

View File

@ -2,19 +2,19 @@ import 'app/polyfills';
import '@testing-library/jest-dom';
if (!window.localStorage) {
window.localStorage = {
getItem(key) {
return this[key] || null;
},
setItem(key, value) {
this[key] = value;
},
removeItem(key) {
delete this[key];
},
};
window.localStorage = {
getItem(key) {
return this[key] || null;
},
setItem(key, value) {
this[key] = value;
},
removeItem(key) {
delete this[key];
},
};
window.sessionStorage = {
...window.localStorage,
};
window.sessionStorage = {
...window.localStorage,
};
}

View File

@ -1,174 +1,174 @@
{
"name": "@elyby/accounts-frontend",
"description": "",
"author": "SleepWalker <mybox@udf.su>",
"private": true,
"maintainers": [
{
"name": "ErickSkrauch",
"email": "erickskrauch@ely.by"
"name": "@elyby/accounts-frontend",
"description": "",
"author": "SleepWalker <mybox@udf.su>",
"private": true,
"maintainers": [
{
"name": "ErickSkrauch",
"email": "erickskrauch@ely.by"
},
{
"name": "SleepWalker",
"email": "mybox@udf.su"
}
],
"license": "Apache-2.0",
"repository": "https://github.com/elyby/accounts-frontend",
"engines": {
"node": ">=10.0.0"
},
{
"name": "SleepWalker",
"email": "mybox@udf.su"
}
],
"license": "Apache-2.0",
"repository": "https://github.com/elyby/accounts-frontend",
"engines": {
"node": ">=10.0.0"
},
"workspaces": [
"packages/*",
"tests-e2e"
],
"scripts": {
"start": "yarn run clean && yarn run build:dll && webpack-dev-server --colors",
"clean": "rm -rf ./build && mkdir ./build",
"e2e": "yarn --cwd ./tests-e2e test",
"test": "jest",
"test:watch": "yarn test --watch",
"lint": "eslint --ext js,ts,tsx --fix .",
"lint:check": "eslint --ext js,ts,tsx --quiet .",
"prettier": "prettier --write .",
"prettier:check": "prettier --check .",
"ts:check": "tsc",
"ci:check": "yarn lint:check && yarn ts:check && yarn test",
"analyze": "yarn run clean && yarn run build:webpack --analyze",
"i18n:collect": "babel-node --extensions \".ts\" ./packages/scripts/i18n-collect.ts",
"i18n:push": "babel-node --extensions \".ts\" ./packages/scripts/i18n-crowdin.ts push",
"i18n:pull": "babel-node --extensions \".ts\" ./packages/scripts/i18n-crowdin.ts pull",
"build": "yarn run clean && yarn run build:webpack",
"build:install": "yarn install",
"build:webpack": "NODE_ENV=production webpack --colors -p --bail",
"build:quiet": "yarn run clean && yarn run build:webpack --quiet",
"build:dll": "babel-node --extensions '.ts,.d.ts' ./packages/scripts/build-dll.ts",
"build:serve": "http-server --proxy https://dev.account.ely.by ./build",
"sb": "APP_ENV=storybook start-storybook -p 9009 --ci",
"sb:build": "APP_ENV=storybook build-storybook"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"pre-push": "yarn ci:check"
}
},
"lint-staged": {
"*.{json,scss,css,md}": [
"prettier --write"
"workspaces": [
"packages/*",
"tests-e2e"
],
"*.{js,ts,tsx}": [
"eslint --fix"
]
},
"jest": {
"roots": [
"<rootDir>/packages/app"
],
"setupFilesAfterEnv": [
"<rootDir>/jest/setupAfterEnv.js"
],
"resetMocks": true,
"restoreMocks": true,
"watchPlugins": [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname"
],
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/jest/__mocks__/mockStrExport.js",
"\\.(css|less|scss)$": "identity-obj-proxy"
"scripts": {
"start": "yarn run clean && yarn run build:dll && webpack-dev-server --colors",
"clean": "rm -rf ./build && mkdir ./build",
"e2e": "yarn --cwd ./tests-e2e test",
"test": "jest",
"test:watch": "yarn test --watch",
"lint": "eslint --ext js,ts,tsx --fix .",
"lint:check": "eslint --ext js,ts,tsx --quiet .",
"prettier": "prettier --write .",
"prettier:check": "prettier --check .",
"ts:check": "tsc",
"ci:check": "yarn lint:check && yarn ts:check && yarn test",
"analyze": "yarn run clean && yarn run build:webpack --analyze",
"i18n:collect": "babel-node --extensions \".ts\" ./packages/scripts/i18n-collect.ts",
"i18n:push": "babel-node --extensions \".ts\" ./packages/scripts/i18n-crowdin.ts push",
"i18n:pull": "babel-node --extensions \".ts\" ./packages/scripts/i18n-crowdin.ts pull",
"build": "yarn run clean && yarn run build:webpack",
"build:install": "yarn install",
"build:webpack": "NODE_ENV=production webpack --colors -p --bail",
"build:quiet": "yarn run clean && yarn run build:webpack --quiet",
"build:dll": "babel-node --extensions '.ts,.d.ts' ./packages/scripts/build-dll.ts",
"build:serve": "http-server --proxy https://dev.account.ely.by ./build",
"sb": "APP_ENV=storybook start-storybook -p 9009 --ci",
"sb:build": "APP_ENV=storybook build-storybook"
},
"transform": {
"\\.intl\\.json$": "<rootDir>/jest/__mocks__/intlMock.js",
"^.+\\.[tj]sx?$": "babel-jest"
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"pre-push": "yarn ci:check"
}
},
"lint-staged": {
"*.{json,scss,css,md}": [
"prettier --write"
],
"*.{js,ts,tsx}": [
"eslint --fix"
]
},
"jest": {
"roots": [
"<rootDir>/packages/app"
],
"setupFilesAfterEnv": [
"<rootDir>/jest/setupAfterEnv.js"
],
"resetMocks": true,
"restoreMocks": true,
"watchPlugins": [
"jest-watch-typeahead/filename",
"jest-watch-typeahead/testname"
],
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/jest/__mocks__/mockStrExport.js",
"\\.(css|less|scss)$": "identity-obj-proxy"
},
"transform": {
"\\.intl\\.json$": "<rootDir>/jest/__mocks__/intlMock.js",
"^.+\\.[tj]sx?$": "babel-jest"
}
},
"dependencies": {
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-hot-loader": "^4.12.18",
"react-intl": "^4.5.7",
"regenerator-runtime": "^0.13.3"
},
"devDependencies": {
"@babel/cli": "^7.8.3",
"@babel/core": "^7.8.3",
"@babel/node": "^7.8.3",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/plugin-proposal-decorators": "^7.8.3",
"@babel/plugin-proposal-do-expressions": "^7.8.3",
"@babel/plugin-proposal-export-default-from": "^7.8.3",
"@babel/plugin-proposal-export-namespace-from": "^7.8.3",
"@babel/plugin-proposal-function-bind": "^7.8.3",
"@babel/plugin-proposal-function-sent": "^7.8.3",
"@babel/plugin-proposal-json-strings": "^7.8.3",
"@babel/plugin-proposal-logical-assignment-operators": "^7.8.3",
"@babel/plugin-proposal-numeric-separator": "^7.8.3",
"@babel/plugin-proposal-pipeline-operator": "^7.8.3",
"@babel/plugin-proposal-throw-expressions": "^7.8.3",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-syntax-import-meta": "^7.8.3",
"@babel/plugin-transform-runtime": "^7.8.3",
"@babel/preset-env": "^7.8.3",
"@babel/preset-react": "^7.8.3",
"@babel/preset-typescript": "^7.8.3",
"@babel/runtime-corejs3": "^7.8.3",
"@storybook/addon-actions": "^5.3.18",
"@storybook/addon-links": "^5.3.18",
"@storybook/addon-viewport": "^5.3.18",
"@storybook/addons": "^5.3.18",
"@storybook/react": "^5.3.18",
"@types/jest": "^25.2.3",
"@types/sinon": "^9.0.3",
"@typescript-eslint/eslint-plugin": "^3.0.0",
"@typescript-eslint/parser": "^3.0.0",
"babel-loader": "^8.0.0",
"babel-plugin-react-intl": "^7.5.10",
"core-js": "3.6.5",
"csp-webpack-plugin": "^2.0.2",
"css-loader": "^3.5.3",
"cssnano": "^4.1.10",
"dotenv": "^8.2.0",
"eager-imports-webpack-plugin": "^1.0.0",
"eslint": "^7.0.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-jsdoc": "^25.4.2",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-react": "^7.20.0",
"exports-loader": "^0.7.0",
"file-loader": "^6.0.0",
"html-loader": "^1.1.0",
"html-webpack-plugin": "^4.3.0",
"husky": "^4.2.5",
"identity-obj-proxy": "^3.0.0",
"imports-loader": "^0.8.0",
"jest": "^26.0.1",
"jest-watch-typeahead": "^0.6.0",
"json-loader": "^0.5.4",
"lint-staged": "^10.2.4",
"loader-utils": "^2.0.0",
"mini-css-extract-plugin": "^0.9.0",
"node-sass": "^4.14.1",
"postcss-import": "^12.0.1",
"postcss-loader": "^3.0.0",
"postcss-scss": "^2.1.1",
"prettier": "^2.0.5",
"raw-loader": "^4.0.1",
"sass-loader": "^8.0.2",
"sinon": "^9.0.2",
"sitemap-webpack-plugin": "^0.8.0",
"speed-measure-webpack-plugin": "^1.3.3",
"storybook-addon-intl": "^2.4.1",
"style-loader": "~1.2.1",
"typescript": "^3.9.3",
"unexpected": "^11.14.0",
"unexpected-sinon": "^10.5.1",
"url-loader": "^4.1.0",
"wait-on": "^5.0.0",
"webpack": "^4.41.5",
"webpack-bundle-analyzer": "^3.8.0",
"webpack-cli": "^3.3.10",
"webpack-dev-server": "^3.10.1",
"webpackbar": "^4.0.0"
}
},
"dependencies": {
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-hot-loader": "^4.12.18",
"react-intl": "^4.5.7",
"regenerator-runtime": "^0.13.3"
},
"devDependencies": {
"@babel/cli": "^7.8.3",
"@babel/core": "^7.8.3",
"@babel/node": "^7.8.3",
"@babel/plugin-proposal-class-properties": "^7.8.3",
"@babel/plugin-proposal-decorators": "^7.8.3",
"@babel/plugin-proposal-do-expressions": "^7.8.3",
"@babel/plugin-proposal-export-default-from": "^7.8.3",
"@babel/plugin-proposal-export-namespace-from": "^7.8.3",
"@babel/plugin-proposal-function-bind": "^7.8.3",
"@babel/plugin-proposal-function-sent": "^7.8.3",
"@babel/plugin-proposal-json-strings": "^7.8.3",
"@babel/plugin-proposal-logical-assignment-operators": "^7.8.3",
"@babel/plugin-proposal-numeric-separator": "^7.8.3",
"@babel/plugin-proposal-pipeline-operator": "^7.8.3",
"@babel/plugin-proposal-throw-expressions": "^7.8.3",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-syntax-import-meta": "^7.8.3",
"@babel/plugin-transform-runtime": "^7.8.3",
"@babel/preset-env": "^7.8.3",
"@babel/preset-react": "^7.8.3",
"@babel/preset-typescript": "^7.8.3",
"@babel/runtime-corejs3": "^7.8.3",
"@storybook/addon-actions": "^5.3.18",
"@storybook/addon-links": "^5.3.18",
"@storybook/addon-viewport": "^5.3.18",
"@storybook/addons": "^5.3.18",
"@storybook/react": "^5.3.18",
"@types/jest": "^25.2.3",
"@types/sinon": "^9.0.3",
"@typescript-eslint/eslint-plugin": "^3.0.0",
"@typescript-eslint/parser": "^3.0.0",
"babel-loader": "^8.0.0",
"babel-plugin-react-intl": "^7.5.10",
"core-js": "3.6.5",
"csp-webpack-plugin": "^2.0.2",
"css-loader": "^3.5.3",
"cssnano": "^4.1.10",
"dotenv": "^8.2.0",
"eager-imports-webpack-plugin": "^1.0.0",
"eslint": "^7.0.0",
"eslint-config-prettier": "^6.11.0",
"eslint-plugin-jsdoc": "^25.4.2",
"eslint-plugin-prettier": "^3.1.3",
"eslint-plugin-react": "^7.20.0",
"exports-loader": "^0.7.0",
"file-loader": "^6.0.0",
"html-loader": "^1.1.0",
"html-webpack-plugin": "^4.3.0",
"husky": "^4.2.5",
"identity-obj-proxy": "^3.0.0",
"imports-loader": "^0.8.0",
"jest": "^26.0.1",
"jest-watch-typeahead": "^0.6.0",
"json-loader": "^0.5.4",
"lint-staged": "^10.2.4",
"loader-utils": "^2.0.0",
"mini-css-extract-plugin": "^0.9.0",
"node-sass": "^4.14.1",
"postcss-import": "^12.0.1",
"postcss-loader": "^3.0.0",
"postcss-scss": "^2.1.1",
"prettier": "^2.0.5",
"raw-loader": "^4.0.1",
"sass-loader": "^8.0.2",
"sinon": "^9.0.2",
"sitemap-webpack-plugin": "^0.8.0",
"speed-measure-webpack-plugin": "^1.3.3",
"storybook-addon-intl": "^2.4.1",
"style-loader": "~1.2.1",
"typescript": "^3.9.3",
"unexpected": "^11.14.0",
"unexpected-sinon": "^10.5.1",
"url-loader": "^4.1.0",
"wait-on": "^5.0.0",
"webpack": "^4.41.5",
"webpack-bundle-analyzer": "^3.8.0",
"webpack-cli": "^3.3.10",
"webpack-dev-server": "^3.10.1",
"webpackbar": "^4.0.0"
}
}

View File

@ -26,47 +26,46 @@ type ChildState = any;
// TODO: this may be rewritten in more efficient way using resize/mutation observer
export default class MeasureHeight extends React.PureComponent<
{
shouldMeasure: (prevState: ChildState, newState: ChildState) => boolean;
onMeasure: (height: number) => void;
state: ChildState;
} & React.HTMLAttributes<HTMLDivElement>
{
shouldMeasure: (prevState: ChildState, newState: ChildState) => boolean;
onMeasure: (height: number) => void;
state: ChildState;
} & React.HTMLAttributes<HTMLDivElement>
> {
static defaultProps = {
shouldMeasure: (prevState: ChildState, newState: ChildState) =>
prevState !== newState,
onMeasure: () => {},
};
static defaultProps = {
shouldMeasure: (prevState: ChildState, newState: ChildState) => prevState !== newState,
onMeasure: () => {},
};
el: HTMLDivElement | null = null;
el: HTMLDivElement | null = null;
componentDidMount() {
// we want to measure height immediately on first mount to avoid ui laggs
this.measure();
window.addEventListener('resize', this.enqueueMeasurement);
}
componentDidUpdate(prevProps: typeof MeasureHeight.prototype.props) {
if (this.props.shouldMeasure(prevProps.state, this.props.state)) {
this.enqueueMeasurement();
componentDidMount() {
// we want to measure height immediately on first mount to avoid ui laggs
this.measure();
window.addEventListener('resize', this.enqueueMeasurement);
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.enqueueMeasurement);
}
componentDidUpdate(prevProps: typeof MeasureHeight.prototype.props) {
if (this.props.shouldMeasure(prevProps.state, this.props.state)) {
this.enqueueMeasurement();
}
}
render() {
const props = omit(this.props, ['shouldMeasure', 'onMeasure', 'state']);
componentWillUnmount() {
window.removeEventListener('resize', this.enqueueMeasurement);
}
return <div {...props} ref={(el: HTMLDivElement) => (this.el = el)} />;
}
render() {
const props = omit(this.props, ['shouldMeasure', 'onMeasure', 'state']);
measure = () => {
requestAnimationFrame(() => {
this.el && this.props.onMeasure(this.el.offsetHeight);
});
};
return <div {...props} ref={(el: HTMLDivElement) => (this.el = el)} />;
}
enqueueMeasurement = debounce(this.measure);
measure = () => {
requestAnimationFrame(() => {
this.el && this.props.onMeasure(this.el.offsetHeight);
});
};
enqueueMeasurement = debounce(this.measure);
}

View File

@ -1,5 +1,5 @@
{
"addAccount": "Add account",
"goToEly": "Go to Ely.by profile",
"logout": "Log out"
"addAccount": "Add account",
"goToEly": "Go to Ely.by profile",
"logout": "Log out"
}

View File

@ -14,190 +14,164 @@ import styles from './accountSwitcher.scss';
import messages from './AccountSwitcher.intl.json';
interface Props {
switchAccount: (account: Account) => Promise<Account>;
removeAccount: (account: Account) => Promise<void>;
// called after each action performed
onAfterAction: () => void;
// called after switching an account. The active account will be passed as arg
onSwitch: (account: Account) => void;
accounts: RootState['accounts'];
skin: Skin;
// whether active account should be expanded and shown on the top
highlightActiveAccount: boolean;
// whether to show logout icon near each account
allowLogout: boolean;
// whether to show add account button
allowAdd: boolean;
switchAccount: (account: Account) => Promise<Account>;
removeAccount: (account: Account) => Promise<void>;
// called after each action performed
onAfterAction: () => void;
// called after switching an account. The active account will be passed as arg
onSwitch: (account: Account) => void;
accounts: RootState['accounts'];
skin: Skin;
// whether active account should be expanded and shown on the top
highlightActiveAccount: boolean;
// whether to show logout icon near each account
allowLogout: boolean;
// whether to show add account button
allowAdd: boolean;
}
export class AccountSwitcher extends React.Component<Props> {
static defaultProps: Partial<Props> = {
skin: SKIN_DARK,
highlightActiveAccount: true,
allowLogout: true,
allowAdd: true,
onAfterAction() {},
onSwitch() {},
};
static defaultProps: Partial<Props> = {
skin: SKIN_DARK,
highlightActiveAccount: true,
allowLogout: true,
allowAdd: true,
onAfterAction() {},
onSwitch() {},
};
render() {
const {
accounts,
skin,
allowAdd,
allowLogout,
highlightActiveAccount,
} = this.props;
const activeAccount = getActiveAccount({ accounts });
render() {
const { accounts, skin, allowAdd, allowLogout, highlightActiveAccount } = this.props;
const activeAccount = getActiveAccount({ accounts });
if (!activeAccount) {
return null;
if (!activeAccount) {
return null;
}
let { available } = accounts;
if (highlightActiveAccount) {
available = available.filter((account) => account.id !== activeAccount.id);
}
return (
<div
className={clsx(styles.accountSwitcher, styles[`${skin}AccountSwitcher`])}
data-testid="account-switcher"
>
{highlightActiveAccount && (
<div className={styles.item} data-testid="active-account">
<div className={clsx(styles.accountIcon, styles.activeAccountIcon, styles.accountIcon1)} />
<div className={styles.activeAccountInfo}>
<div className={styles.activeAccountUsername}>{activeAccount.username}</div>
<div className={clsx(styles.accountEmail, styles.activeAccountEmail)}>
{activeAccount.email}
</div>
<div className={styles.links}>
<div className={styles.link}>
<a href={`http://ely.by/u${activeAccount.id}`} target="_blank">
<Message {...messages.goToEly} />
</a>
</div>
<div className={styles.link}>
<a
className={styles.link}
data-testid="logout-account"
onClick={this.onRemove(activeAccount)}
href="#"
>
<Message {...messages.logout} />
</a>
</div>
</div>
</div>
</div>
)}
{available.map((account, index) => (
<div
className={clsx(styles.item, styles.accountSwitchItem)}
key={account.id}
data-e2e-account-id={account.id}
onClick={this.onSwitch(account)}
>
<div
className={clsx(
styles.accountIcon,
styles[`accountIcon${(index % 7) + (highlightActiveAccount ? 2 : 1)}`],
)}
/>
{allowLogout ? (
<div
className={styles.logoutIcon}
data-testid="logout-account"
onClick={this.onRemove(account)}
/>
) : (
<div className={styles.nextIcon} />
)}
<div className={styles.accountInfo}>
<div className={styles.accountUsername}>{account.username}</div>
<div className={styles.accountEmail}>{account.email}</div>
</div>
</div>
))}
{allowAdd ? (
<Link to="/login" onClick={this.props.onAfterAction}>
<Button
color={COLOR_WHITE}
data-testid="add-account"
block
small
className={styles.addAccount}
label={
<Message {...messages.addAccount}>
{(message) => (
<span>
<div className={styles.addIcon} />
{message}
</span>
)}
</Message>
}
/>
</Link>
) : null}
</div>
);
}
let { available } = accounts;
onSwitch = (account: Account) => (event: React.MouseEvent<any>) => {
event.preventDefault();
if (highlightActiveAccount) {
available = available.filter(
(account) => account.id !== activeAccount.id,
);
}
loader.show();
return (
<div
className={clsx(
styles.accountSwitcher,
styles[`${skin}AccountSwitcher`],
)}
data-testid="account-switcher"
>
{highlightActiveAccount && (
<div className={styles.item} data-testid="active-account">
<div
className={clsx(
styles.accountIcon,
styles.activeAccountIcon,
styles.accountIcon1,
)}
/>
<div className={styles.activeAccountInfo}>
<div className={styles.activeAccountUsername}>
{activeAccount.username}
</div>
<div
className={clsx(styles.accountEmail, styles.activeAccountEmail)}
>
{activeAccount.email}
</div>
<div className={styles.links}>
<div className={styles.link}>
<a
href={`http://ely.by/u${activeAccount.id}`}
target="_blank"
>
<Message {...messages.goToEly} />
</a>
</div>
<div className={styles.link}>
<a
className={styles.link}
data-testid="logout-account"
onClick={this.onRemove(activeAccount)}
href="#"
>
<Message {...messages.logout} />
</a>
</div>
</div>
</div>
</div>
)}
this.props
.switchAccount(account)
.finally(() => this.props.onAfterAction())
.then(() => this.props.onSwitch(account))
// we won't sent any logs to sentry, because an error should be already
// handled by external logic
.catch((error) => console.warn('Error switching account', { error }))
.finally(() => loader.hide());
};
{available.map((account, index) => (
<div
className={clsx(styles.item, styles.accountSwitchItem)}
key={account.id}
data-e2e-account-id={account.id}
onClick={this.onSwitch(account)}
>
<div
className={clsx(
styles.accountIcon,
styles[
`accountIcon${(index % 7) + (highlightActiveAccount ? 2 : 1)}`
],
)}
/>
onRemove = (account: Account) => (event: React.MouseEvent<any>) => {
event.preventDefault();
event.stopPropagation();
{allowLogout ? (
<div
className={styles.logoutIcon}
data-testid="logout-account"
onClick={this.onRemove(account)}
/>
) : (
<div className={styles.nextIcon} />
)}
<div className={styles.accountInfo}>
<div className={styles.accountUsername}>{account.username}</div>
<div className={styles.accountEmail}>{account.email}</div>
</div>
</div>
))}
{allowAdd ? (
<Link to="/login" onClick={this.props.onAfterAction}>
<Button
color={COLOR_WHITE}
data-testid="add-account"
block
small
className={styles.addAccount}
label={
<Message {...messages.addAccount}>
{(message) => (
<span>
<div className={styles.addIcon} />
{message}
</span>
)}
</Message>
}
/>
</Link>
) : null}
</div>
);
}
onSwitch = (account: Account) => (event: React.MouseEvent<any>) => {
event.preventDefault();
loader.show();
this.props
.switchAccount(account)
.finally(() => this.props.onAfterAction())
.then(() => this.props.onSwitch(account))
// we won't sent any logs to sentry, because an error should be already
// handled by external logic
.catch((error) => console.warn('Error switching account', { error }))
.finally(() => loader.hide());
};
onRemove = (account: Account) => (event: React.MouseEvent<any>) => {
event.preventDefault();
event.stopPropagation();
this.props.removeAccount(account).then(() => this.props.onAfterAction());
};
this.props.removeAccount(account).then(() => this.props.onAfterAction());
};
}
export default connect(
({ accounts }: RootState) => ({
accounts,
}),
{
switchAccount: authenticate,
removeAccount: revoke,
},
({ accounts }: RootState) => ({
accounts,
}),
{
switchAccount: authenticate,
removeAccount: revoke,
},
)(AccountSwitcher);

View File

@ -8,7 +8,7 @@ $bodyLeftRightPadding: 20px;
$lightBorderColor: #eee;
.accountSwitcher {
text-align: left;
text-align: left;
}
.accountInfo {
@ -16,210 +16,210 @@ $lightBorderColor: #eee;
.accountUsername,
.accountEmail {
overflow: hidden;
text-overflow: ellipsis;
overflow: hidden;
text-overflow: ellipsis;
}
.lightAccountSwitcher {
background: #fff;
color: #444;
min-width: 205px;
background: #fff;
color: #444;
min-width: 205px;
$border: 1px solid $lightBorderColor;
border-left: $border;
border-right: $border;
border-bottom: 7px solid darker($green);
$border: 1px solid $lightBorderColor;
border-left: $border;
border-right: $border;
border-bottom: 7px solid darker($green);
.item {
padding: 15px;
border-bottom: 1px solid $lightBorderColor;
}
.accountSwitchItem {
cursor: pointer;
transition: 0.25s;
&:hover {
background-color: $whiteButtonLight;
.item {
padding: 15px;
border-bottom: 1px solid $lightBorderColor;
}
&:active {
background-color: $whiteButtonDark;
.accountSwitchItem {
cursor: pointer;
transition: 0.25s;
&:hover {
background-color: $whiteButtonLight;
}
&:active {
background-color: $whiteButtonDark;
}
}
}
.accountIcon {
font-size: 27px;
width: 20px;
text-align: center;
}
.activeAccountIcon {
font-size: 40px;
}
.activeAccountInfo {
margin-left: 29px;
}
.activeAccountUsername {
font-family: $font-family-title;
font-size: 20px;
color: $green;
}
.activeAccountEmail {
}
.links {
margin-top: 6px;
}
.link {
font-size: 12px;
margin-bottom: 3px;
white-space: nowrap;
&:last-of-type {
margin-bottom: 0;
.accountIcon {
font-size: 27px;
width: 20px;
text-align: center;
}
}
.accountInfo {
margin-left: 29px;
margin-right: 25px;
}
.activeAccountIcon {
font-size: 40px;
}
.accountUsername {
font-family: $font-family-title;
font-size: 14px;
color: #666;
}
.activeAccountInfo {
margin-left: 29px;
}
.accountEmail {
font-size: 10px;
color: #999;
}
.activeAccountUsername {
font-family: $font-family-title;
font-size: 20px;
color: $green;
}
.addAccount {
}
.activeAccountEmail {
}
.links {
margin-top: 6px;
}
.link {
font-size: 12px;
margin-bottom: 3px;
white-space: nowrap;
&:last-of-type {
margin-bottom: 0;
}
}
.accountInfo {
margin-left: 29px;
margin-right: 25px;
}
.accountUsername {
font-family: $font-family-title;
font-size: 14px;
color: #666;
}
.accountEmail {
font-size: 10px;
color: #999;
}
.addAccount {
}
}
.darkAccountSwitcher {
background: $black;
background: $black;
$border: 1px solid lighter($black);
$border: 1px solid lighter($black);
.item {
padding: 15px 20px;
border-top: 1px solid lighter($black);
transition: 0.25s;
cursor: pointer;
.item {
padding: 15px 20px;
border-top: 1px solid lighter($black);
transition: 0.25s;
cursor: pointer;
&:hover {
background-color: lighter($black);
&:hover {
background-color: lighter($black);
}
&:active {
background-color: darker($black);
}
&:last-of-type {
border-bottom: $border;
}
}
&:active {
background-color: darker($black);
.accountIcon {
font-size: 35px;
}
&:last-of-type {
border-bottom: $border;
.accountInfo {
margin-left: 30px;
margin-right: 26px;
}
}
.accountIcon {
font-size: 35px;
}
.accountUsername {
font-family: $font-family-title;
color: #fff;
}
.accountInfo {
margin-left: 30px;
margin-right: 26px;
}
.accountUsername {
font-family: $font-family-title;
color: #fff;
}
.accountEmail {
color: #666;
font-size: 12px;
}
.accountEmail {
color: #666;
font-size: 12px;
}
}
.accountIcon {
composes: minecraft-character from '~app/components/ui/icons.scss';
composes: minecraft-character from '~app/components/ui/icons.scss';
float: left;
float: left;
&1 {
color: $green;
}
&1 {
color: $green;
}
&2 {
color: $blue;
}
&2 {
color: $blue;
}
&3 {
color: $violet;
}
&3 {
color: $violet;
}
&4 {
color: $orange;
}
&4 {
color: $orange;
}
&5 {
color: $dark_blue;
}
&5 {
color: $dark_blue;
}
&6 {
color: $light_violet;
}
&6 {
color: $light_violet;
}
&7 {
color: $red;
}
&7 {
color: $red;
}
}
.addIcon {
composes: plus from '~app/components/ui/icons.scss';
composes: plus from '~app/components/ui/icons.scss';
color: $green;
position: relative;
bottom: 1px;
margin-right: 3px;
color: $green;
position: relative;
bottom: 1px;
margin-right: 3px;
}
.nextIcon {
composes: arrowRight from '~app/components/ui/icons.scss';
composes: arrowRight from '~app/components/ui/icons.scss';
position: relative;
float: right;
position: relative;
float: right;
font-size: 24px;
color: #4e4e4e;
line-height: 35px;
left: 0;
font-size: 24px;
color: #4e4e4e;
line-height: 35px;
left: 0;
transition: color 0.25s, left 0.5s;
transition: color 0.25s, left 0.5s;
.item:hover & {
color: #aaa;
left: 5px;
}
.item:hover & {
color: #aaa;
left: 5px;
}
}
.logoutIcon {
composes: exit from '~app/components/ui/icons.scss';
composes: exit from '~app/components/ui/icons.scss';
color: #cdcdcd;
float: right;
line-height: 27px;
transition: 0.25s;
color: #cdcdcd;
float: right;
line-height: 27px;
transition: 0.25s;
&:hover {
color: #777;
}
&:hover {
color: #777;
}
}

View File

@ -4,491 +4,420 @@ import { browserHistory } from 'app/services/history';
import { InternalServerError } from 'app/services/request';
import { sessionStorage } from 'app/services/localStorage';
import * as authentication from 'app/services/api/authentication';
import {
authenticate,
revoke,
logoutAll,
logoutStrangers,
} from 'app/components/accounts/actions';
import {
add,
activate,
remove,
reset,
} from 'app/components/accounts/actions/pure-actions';
import { authenticate, revoke, logoutAll, logoutStrangers } from 'app/components/accounts/actions';
import { add, activate, remove, reset } from 'app/components/accounts/actions/pure-actions';
import { updateUser, setUser } from 'app/components/user/actions';
import { setLogin, setAccountSwitcher } from 'app/components/auth/actions';
import { Dispatch, RootState } from 'app/reducers';
import { Account } from './reducer';
const token =
'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJlbHl8MSJ9.pRJ7vakt2eIscjqwG__KhSxKb3qwGsdBBeDbBffJs_I';
const legacyToken =
'eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOjF9.cRF-sQNrwWQ94xCb3vWioVdjxAZeefEE7GMGwh7708o';
const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJlbHl8MSJ9.pRJ7vakt2eIscjqwG__KhSxKb3qwGsdBBeDbBffJs_I';
const legacyToken = 'eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOjF9.cRF-sQNrwWQ94xCb3vWioVdjxAZeefEE7GMGwh7708o';
const account = {
id: 1,
username: 'username',
email: 'email@test.com',
token,
refreshToken: 'bar',
id: 1,
username: 'username',
email: 'email@test.com',
token,
refreshToken: 'bar',
};
const user = {
id: 1,
username: 'username',
email: 'email@test.com',
lang: 'be',
id: 1,
username: 'username',
email: 'email@test.com',
lang: 'be',
};
describe('components/accounts/actions', () => {
let dispatch: Dispatch;
let getState: () => RootState;
let dispatch: Dispatch;
let getState: () => RootState;
beforeEach(() => {
dispatch = sinon
.spy((arg) => (typeof arg === 'function' ? arg(dispatch, getState) : arg))
.named('store.dispatch');
getState = sinon.stub().named('store.getState');
beforeEach(() => {
dispatch = sinon
.spy((arg) => (typeof arg === 'function' ? arg(dispatch, getState) : arg))
.named('store.dispatch');
getState = sinon.stub().named('store.getState');
(getState as any).returns({
accounts: {
available: [],
active: null,
},
auth: {
credentials: {},
},
user: {},
});
sinon
.stub(authentication, 'validateToken')
.named('authentication.validateToken');
sinon.stub(browserHistory, 'push').named('browserHistory.push');
sinon.stub(authentication, 'logout').named('authentication.logout');
(authentication.logout as any).returns(Promise.resolve());
(authentication.validateToken as any).returns(
Promise.resolve({
token: account.token,
refreshToken: account.refreshToken,
user,
}),
);
});
afterEach(() => {
(authentication.validateToken as any).restore();
(authentication.logout as any).restore();
(browserHistory.push as any).restore();
});
describe('#authenticate()', () => {
it('should request user state using token', () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(authentication.validateToken, 'to have a call satisfying', [
account.id,
account.token,
account.refreshToken,
]),
));
it('should request user by extracting id from token', () =>
authenticate({ token } as Account)(
dispatch,
getState,
undefined,
).then(() =>
expect(authentication.validateToken, 'to have a call satisfying', [
1,
token,
undefined,
]),
));
it('should request user by extracting id from legacy token', () =>
authenticate({ token: legacyToken } as Account)(
dispatch,
getState,
undefined,
).then(() =>
expect(authentication.validateToken, 'to have a call satisfying', [
1,
legacyToken,
undefined,
]),
));
it(`dispatches accounts:add action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [add(account)]),
));
it(`dispatches accounts:activate action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]),
));
it(`dispatches i18n:setLocale action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [
{ type: 'i18n:setLocale', payload: { locale: 'be' } },
]),
));
it('should update user state', () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [
updateUser({ ...user, isGuest: false }),
]),
));
it('resolves with account', () =>
authenticate(account)(dispatch, getState, undefined).then((resp) =>
expect(resp, 'to equal', account),
));
it('rejects when bad auth data', () => {
(authentication.validateToken as any).returns(Promise.reject({}));
return expect(
authenticate(account)(dispatch, getState, undefined),
'to be rejected',
).then(() => {
expect(dispatch, 'to have a call satisfying', [
setLogin(account.email),
]);
expect(browserHistory.push, 'to have a call satisfying', ['/login']);
});
});
it('rejects when 5xx without logouting', () => {
const resp = new InternalServerError('500', { status: 500 });
(authentication.validateToken as any).rejects(resp);
return expect(
authenticate(account)(dispatch, getState, undefined),
'to be rejected with',
resp,
).then(() =>
expect(dispatch, 'to have no calls satisfying', [
{ payload: { isGuest: true } },
]),
);
});
it('marks user as stranger, if there is no refreshToken', () => {
const expectedKey = `stranger${account.id}`;
(authentication.validateToken as any).resolves({
token: account.token,
user,
});
sessionStorage.removeItem(expectedKey);
return authenticate(account)(dispatch, getState, undefined).then(() => {
expect(sessionStorage.getItem(expectedKey), 'not to be null');
sessionStorage.removeItem(expectedKey);
});
});
describe('when user authenticated during oauth', () => {
beforeEach(() => {
(getState as any).returns({
accounts: {
available: [],
active: null,
},
user: {},
auth: {
oauth: {
clientId: 'ely.by',
prompt: [],
accounts: {
available: [],
active: null,
},
},
auth: {
credentials: {},
},
user: {},
});
});
it('should dispatch setAccountSwitcher', () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [
setAccountSwitcher(false),
]),
));
});
sinon.stub(authentication, 'validateToken').named('authentication.validateToken');
sinon.stub(browserHistory, 'push').named('browserHistory.push');
sinon.stub(authentication, 'logout').named('authentication.logout');
describe('when one account available', () => {
beforeEach(() => {
(getState as any).returns({
accounts: {
active: account.id,
available: [account],
},
auth: {
credentials: {},
},
user,
});
});
it('should activate account before auth api call', () => {
(authentication.logout as any).returns(Promise.resolve());
(authentication.validateToken as any).returns(
Promise.reject({ error: 'foo' }),
Promise.resolve({
token: account.token,
refreshToken: account.refreshToken,
user,
}),
);
return expect(
authenticate(account)(dispatch, getState, undefined),
'to be rejected with',
{ error: 'foo' },
).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]),
);
});
});
});
describe('#revoke()', () => {
describe('when one account available', () => {
beforeEach(() => {
(getState as any).returns({
accounts: {
active: account.id,
available: [account],
},
auth: {
credentials: {},
},
user,
});
});
it('should dispatch reset action', () =>
revoke(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [reset()]),
));
it('should call logout api method in background', () =>
revoke(account)(dispatch, getState, undefined).then(() =>
expect(authentication.logout, 'to have a call satisfying', [
account.token,
]),
));
it('should update user state', () =>
revoke(account)(dispatch, getState, undefined).then(
() =>
expect(dispatch, 'to have a call satisfying', [
setUser({ isGuest: true }),
]),
// expect(dispatch, 'to have calls satisfying', [
// [remove(account)],
// [expect.it('to be a function')]
// // [logout()] // TODO: this is not a plain action. How should we simplify its testing?
// ])
));
});
describe('when multiple accounts available', () => {
const account2 = { ...account, id: 2 };
beforeEach(() => {
(getState as any).returns({
accounts: {
active: account2.id,
available: [account, account2],
},
user,
});
});
it('should switch to the next account', () =>
revoke(account2)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]),
));
it('should remove current account', () =>
revoke(account2)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [remove(account2)]),
));
it('should call logout api method in background', () =>
revoke(account2)(dispatch, getState, undefined).then(() =>
expect(authentication.logout, 'to have a call satisfying', [
account2.token,
]),
));
});
});
describe('#logoutAll()', () => {
const account2 = { ...account, id: 2 };
beforeEach(() => {
(getState as any).returns({
accounts: {
active: account2.id,
available: [account, account2],
},
auth: {
credentials: {},
},
user,
});
afterEach(() => {
(authentication.validateToken as any).restore();
(authentication.logout as any).restore();
(browserHistory.push as any).restore();
});
it('should call logout api method for each account', () => {
logoutAll()(dispatch, getState, undefined);
describe('#authenticate()', () => {
it('should request user state using token', () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(authentication.validateToken, 'to have a call satisfying', [
account.id,
account.token,
account.refreshToken,
]),
));
expect(authentication.logout, 'to have calls satisfying', [
[account.token],
[account2.token],
]);
});
it('should request user by extracting id from token', () =>
authenticate({ token } as Account)(dispatch, getState, undefined).then(() =>
expect(authentication.validateToken, 'to have a call satisfying', [1, token, undefined]),
));
it('should dispatch reset', () => {
logoutAll()(dispatch, getState, undefined);
it('should request user by extracting id from legacy token', () =>
authenticate({ token: legacyToken } as Account)(dispatch, getState, undefined).then(() =>
expect(authentication.validateToken, 'to have a call satisfying', [1, legacyToken, undefined]),
));
expect(dispatch, 'to have a call satisfying', [reset()]);
});
it(`dispatches accounts:add action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [add(account)]),
));
it('should redirect to /login', () =>
logoutAll()(dispatch, getState, undefined).then(() => {
expect(browserHistory.push, 'to have a call satisfying', ['/login']);
}));
it(`dispatches accounts:activate action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]),
));
it('should change user to guest', () =>
logoutAll()(dispatch, getState, undefined).then(() => {
expect(dispatch, 'to have a call satisfying', [
setUser({
lang: user.lang,
isGuest: true,
}),
]);
}));
});
it(`dispatches i18n:setLocale action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [{ type: 'i18n:setLocale', payload: { locale: 'be' } }]),
));
describe('#logoutStrangers', () => {
const foreignAccount = {
...account,
id: 2,
refreshToken: null,
};
it('should update user state', () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [updateUser({ ...user, isGuest: false })]),
));
const foreignAccount2 = {
...foreignAccount,
id: 3,
};
it('resolves with account', () =>
authenticate(account)(dispatch, getState, undefined).then((resp) => expect(resp, 'to equal', account)));
beforeEach(() => {
(getState as any).returns({
accounts: {
active: foreignAccount.id,
available: [account, foreignAccount, foreignAccount2],
},
user,
});
});
it('rejects when bad auth data', () => {
(authentication.validateToken as any).returns(Promise.reject({}));
it('should remove stranger accounts', () => {
logoutStrangers()(dispatch, getState, undefined);
return expect(authenticate(account)(dispatch, getState, undefined), 'to be rejected').then(() => {
expect(dispatch, 'to have a call satisfying', [setLogin(account.email)]);
expect(dispatch, 'to have a call satisfying', [remove(foreignAccount)]);
expect(dispatch, 'to have a call satisfying', [remove(foreignAccount2)]);
});
it('should logout stranger accounts', () => {
logoutStrangers()(dispatch, getState, undefined);
expect(authentication.logout, 'to have calls satisfying', [
[foreignAccount.token],
[foreignAccount2.token],
]);
});
it('should activate another account if available', () =>
logoutStrangers()(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]),
));
it('should not activate another account if active account is already not a stranger', () => {
(getState as any).returns({
accounts: {
active: account.id,
available: [account, foreignAccount],
},
user,
});
return logoutStrangers()(dispatch, getState, undefined).then(() =>
expect(dispatch, 'not to have calls satisfying', [activate(account)]),
);
});
it('should not dispatch if no strangers', () => {
(getState as any).returns({
accounts: {
active: account.id,
available: [account],
},
user,
});
return logoutStrangers()(dispatch, getState, undefined).then(() =>
expect(dispatch, 'was not called'),
);
});
describe('when all accounts are strangers', () => {
beforeEach(() => {
(getState as any).returns({
accounts: {
active: foreignAccount.id,
available: [foreignAccount, foreignAccount2],
},
auth: {
credentials: {},
},
user,
expect(browserHistory.push, 'to have a call satisfying', ['/login']);
});
});
logoutStrangers()(dispatch, getState, undefined);
});
it('rejects when 5xx without logouting', () => {
const resp = new InternalServerError('500', { status: 500 });
it('logouts all accounts', () => {
expect(authentication.logout, 'to have calls satisfying', [
[foreignAccount.token],
[foreignAccount2.token],
]);
(authentication.validateToken as any).rejects(resp);
expect(dispatch, 'to have a call satisfying', [
setUser({ isGuest: true }),
]);
return expect(authenticate(account)(dispatch, getState, undefined), 'to be rejected with', resp).then(() =>
expect(dispatch, 'to have no calls satisfying', [{ payload: { isGuest: true } }]),
);
});
expect(dispatch, 'to have a call satisfying', [reset()]);
});
it('marks user as stranger, if there is no refreshToken', () => {
const expectedKey = `stranger${account.id}`;
(authentication.validateToken as any).resolves({
token: account.token,
user,
});
sessionStorage.removeItem(expectedKey);
return authenticate(account)(dispatch, getState, undefined).then(() => {
expect(sessionStorage.getItem(expectedKey), 'not to be null');
sessionStorage.removeItem(expectedKey);
});
});
describe('when user authenticated during oauth', () => {
beforeEach(() => {
(getState as any).returns({
accounts: {
available: [],
active: null,
},
user: {},
auth: {
oauth: {
clientId: 'ely.by',
prompt: [],
},
},
});
});
it('should dispatch setAccountSwitcher', () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [setAccountSwitcher(false)]),
));
});
describe('when one account available', () => {
beforeEach(() => {
(getState as any).returns({
accounts: {
active: account.id,
available: [account],
},
auth: {
credentials: {},
},
user,
});
});
it('should activate account before auth api call', () => {
(authentication.validateToken as any).returns(Promise.reject({ error: 'foo' }));
return expect(authenticate(account)(dispatch, getState, undefined), 'to be rejected with', {
error: 'foo',
}).then(() => expect(dispatch, 'to have a call satisfying', [activate(account)]));
});
});
});
describe('when a stranger has a mark in sessionStorage', () => {
const key = `stranger${foreignAccount.id}`;
describe('#revoke()', () => {
describe('when one account available', () => {
beforeEach(() => {
(getState as any).returns({
accounts: {
active: account.id,
available: [account],
},
auth: {
credentials: {},
},
user,
});
});
beforeEach(() => {
sessionStorage.setItem(key, '1');
it('should dispatch reset action', () =>
revoke(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [reset()]),
));
logoutStrangers()(dispatch, getState, undefined);
});
it('should call logout api method in background', () =>
revoke(account)(dispatch, getState, undefined).then(() =>
expect(authentication.logout, 'to have a call satisfying', [account.token]),
));
afterEach(() => {
sessionStorage.removeItem(key);
});
it('should update user state', () =>
revoke(account)(dispatch, getState, undefined).then(
() => expect(dispatch, 'to have a call satisfying', [setUser({ isGuest: true })]),
// expect(dispatch, 'to have calls satisfying', [
// [remove(account)],
// [expect.it('to be a function')]
// // [logout()] // TODO: this is not a plain action. How should we simplify its testing?
// ])
));
});
it('should not log out', () =>
expect(dispatch, 'not to have calls satisfying', [
{ payload: foreignAccount },
]));
describe('when multiple accounts available', () => {
const account2 = { ...account, id: 2 };
beforeEach(() => {
(getState as any).returns({
accounts: {
active: account2.id,
available: [account, account2],
},
user,
});
});
it('should switch to the next account', () =>
revoke(account2)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]),
));
it('should remove current account', () =>
revoke(account2)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [remove(account2)]),
));
it('should call logout api method in background', () =>
revoke(account2)(dispatch, getState, undefined).then(() =>
expect(authentication.logout, 'to have a call satisfying', [account2.token]),
));
});
});
describe('#logoutAll()', () => {
const account2 = { ...account, id: 2 };
beforeEach(() => {
(getState as any).returns({
accounts: {
active: account2.id,
available: [account, account2],
},
auth: {
credentials: {},
},
user,
});
});
it('should call logout api method for each account', () => {
logoutAll()(dispatch, getState, undefined);
expect(authentication.logout, 'to have calls satisfying', [[account.token], [account2.token]]);
});
it('should dispatch reset', () => {
logoutAll()(dispatch, getState, undefined);
expect(dispatch, 'to have a call satisfying', [reset()]);
});
it('should redirect to /login', () =>
logoutAll()(dispatch, getState, undefined).then(() => {
expect(browserHistory.push, 'to have a call satisfying', ['/login']);
}));
it('should change user to guest', () =>
logoutAll()(dispatch, getState, undefined).then(() => {
expect(dispatch, 'to have a call satisfying', [
setUser({
lang: user.lang,
isGuest: true,
}),
]);
}));
});
describe('#logoutStrangers', () => {
const foreignAccount = {
...account,
id: 2,
refreshToken: null,
};
const foreignAccount2 = {
...foreignAccount,
id: 3,
};
beforeEach(() => {
(getState as any).returns({
accounts: {
active: foreignAccount.id,
available: [account, foreignAccount, foreignAccount2],
},
user,
});
});
it('should remove stranger accounts', () => {
logoutStrangers()(dispatch, getState, undefined);
expect(dispatch, 'to have a call satisfying', [remove(foreignAccount)]);
expect(dispatch, 'to have a call satisfying', [remove(foreignAccount2)]);
});
it('should logout stranger accounts', () => {
logoutStrangers()(dispatch, getState, undefined);
expect(authentication.logout, 'to have calls satisfying', [
[foreignAccount.token],
[foreignAccount2.token],
]);
});
it('should activate another account if available', () =>
logoutStrangers()(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]),
));
it('should not activate another account if active account is already not a stranger', () => {
(getState as any).returns({
accounts: {
active: account.id,
available: [account, foreignAccount],
},
user,
});
return logoutStrangers()(dispatch, getState, undefined).then(() =>
expect(dispatch, 'not to have calls satisfying', [activate(account)]),
);
});
it('should not dispatch if no strangers', () => {
(getState as any).returns({
accounts: {
active: account.id,
available: [account],
},
user,
});
return logoutStrangers()(dispatch, getState, undefined).then(() => expect(dispatch, 'was not called'));
});
describe('when all accounts are strangers', () => {
beforeEach(() => {
(getState as any).returns({
accounts: {
active: foreignAccount.id,
available: [foreignAccount, foreignAccount2],
},
auth: {
credentials: {},
},
user,
});
logoutStrangers()(dispatch, getState, undefined);
});
it('logouts all accounts', () => {
expect(authentication.logout, 'to have calls satisfying', [
[foreignAccount.token],
[foreignAccount2.token],
]);
expect(dispatch, 'to have a call satisfying', [setUser({ isGuest: true })]);
expect(dispatch, 'to have a call satisfying', [reset()]);
});
});
describe('when a stranger has a mark in sessionStorage', () => {
const key = `stranger${foreignAccount.id}`;
beforeEach(() => {
sessionStorage.setItem(key, '1');
logoutStrangers()(dispatch, getState, undefined);
});
afterEach(() => {
sessionStorage.removeItem(key);
});
it('should not log out', () =>
expect(dispatch, 'not to have calls satisfying', [{ payload: foreignAccount }]));
});
});
});
});

View File

@ -1,27 +1,14 @@
import { getJwtPayloads } from 'app/functions';
import { sessionStorage } from 'app/services/localStorage';
import {
validateToken,
requestToken,
logout,
} from 'app/services/api/authentication';
import {
relogin as navigateToLogin,
setAccountSwitcher,
} from 'app/components/auth/actions';
import { validateToken, requestToken, logout } from 'app/services/api/authentication';
import { relogin as navigateToLogin, setAccountSwitcher } from 'app/components/auth/actions';
import { updateUser, setGuest } from 'app/components/user/actions';
import { setLocale } from 'app/components/i18n/actions';
import logger from 'app/services/logger';
import { ThunkAction } from 'app/reducers';
import { getActiveAccount, Account } from './reducer';
import {
add,
remove,
activate,
reset,
updateToken,
} from './actions/pure-actions';
import { add, remove, activate, reset, updateToken } from './actions/pure-actions';
export { updateToken, activate, remove };
@ -33,120 +20,118 @@ export { updateToken, activate, remove };
* @returns {Function}
*/
export function authenticate(
account:
| Account
| {
token: string;
refreshToken: string | null;
},
account:
| Account
| {
token: string;
refreshToken: string | null;
},
): ThunkAction<Promise<Account>> {
const { token, refreshToken } = account;
const email = 'email' in account ? account.email : null;
const { token, refreshToken } = account;
const email = 'email' in account ? account.email : null;
return async (dispatch, getState) => {
let accountId: number;
return async (dispatch, getState) => {
let accountId: number;
if ('id' in account && typeof account.id === 'number') {
accountId = account.id;
} else {
accountId = findAccountIdFromToken(token);
}
if ('id' in account && typeof account.id === 'number') {
accountId = account.id;
} else {
accountId = findAccountIdFromToken(token);
}
const knownAccount = getState().accounts.available.find(
(item) => item.id === accountId,
);
const knownAccount = getState().accounts.available.find((item) => item.id === accountId);
if (knownAccount) {
// this account is already available
// activate it before validation
dispatch(activate(knownAccount));
}
if (knownAccount) {
// this account is already available
// activate it before validation
dispatch(activate(knownAccount));
}
try {
const {
token: newToken,
refreshToken: newRefreshToken,
user,
} = await validateToken(accountId, token, refreshToken);
const { auth } = getState();
const newAccount: Account = {
id: user.id,
username: user.username,
email: user.email,
token: newToken,
refreshToken: newRefreshToken,
};
dispatch(add(newAccount));
dispatch(activate(newAccount));
dispatch(
updateUser({
isGuest: false,
...user,
}),
);
try {
const { token: newToken, refreshToken: newRefreshToken, user } = await validateToken(
accountId,
token,
refreshToken,
);
const { auth } = getState();
const newAccount: Account = {
id: user.id,
username: user.username,
email: user.email,
token: newToken,
refreshToken: newRefreshToken,
};
dispatch(add(newAccount));
dispatch(activate(newAccount));
dispatch(
updateUser({
isGuest: false,
...user,
}),
);
// TODO: probably should be moved from here, because it is a side effect
logger.setUser(user);
// TODO: probably should be moved from here, because it is a side effect
logger.setUser(user);
if (!newRefreshToken) {
// mark user as stranger (user does not want us to remember his account)
sessionStorage.setItem(`stranger${newAccount.id}`, '1');
}
if (!newRefreshToken) {
// mark user as stranger (user does not want us to remember his account)
sessionStorage.setItem(`stranger${newAccount.id}`, '1');
}
if (auth && auth.oauth && auth.oauth.clientId) {
// if we authenticating during oauth, we disable account chooser
// because user probably has made his choise now
// this may happen, when user registers, logs in or uses account
// chooser panel during oauth
dispatch(setAccountSwitcher(false));
}
if (auth && auth.oauth && auth.oauth.clientId) {
// if we authenticating during oauth, we disable account chooser
// because user probably has made his choise now
// this may happen, when user registers, logs in or uses account
// chooser panel during oauth
dispatch(setAccountSwitcher(false));
}
await dispatch(setLocale(user.lang));
await dispatch(setLocale(user.lang));
return newAccount;
} catch (resp) {
// all the logic to get the valid token was failed,
// looks like we have some problems with token
// lets redirect to login page
if (typeof email === 'string') {
// TODO: we should somehow try to find email by token
dispatch(relogin(email));
}
return newAccount;
} catch (resp) {
// all the logic to get the valid token was failed,
// looks like we have some problems with token
// lets redirect to login page
if (typeof email === 'string') {
// TODO: we should somehow try to find email by token
dispatch(relogin(email));
}
throw resp;
}
};
throw resp;
}
};
}
/**
* Re-fetch user data for currently active account
*/
export function refreshUserData(): ThunkAction<Promise<void>> {
return async (dispatch, getState) => {
const activeAccount = getActiveAccount(getState());
return async (dispatch, getState) => {
const activeAccount = getActiveAccount(getState());
if (!activeAccount) {
throw new Error('Can not fetch user data. No user.id available');
}
if (!activeAccount) {
throw new Error('Can not fetch user data. No user.id available');
}
await dispatch(authenticate(activeAccount));
};
await dispatch(authenticate(activeAccount));
};
}
function findAccountIdFromToken(token: string): number {
const { sub, jti } = getJwtPayloads(token);
const { sub, jti } = getJwtPayloads(token);
// sub has the format "ely|{accountId}", so we must trim "ely|" part
if (sub) {
return parseInt(sub.substr(4), 10);
}
// sub has the format "ely|{accountId}", so we must trim "ely|" part
if (sub) {
return parseInt(sub.substr(4), 10);
}
// In older backend versions identity was stored in jti claim. Some users still have such tokens
if (jti) {
return jti;
}
// In older backend versions identity was stored in jti claim. Some users still have such tokens
if (jti) {
return jti;
}
throw new Error('payloads is not contains any identity claim');
throw new Error('payloads is not contains any identity claim');
}
/**
@ -158,28 +143,28 @@ function findAccountIdFromToken(token: string): number {
* @returns {Function}
*/
export function ensureToken(): ThunkAction<Promise<void>> {
return (dispatch, getState) => {
const { token } = getActiveAccount(getState()) || {};
return (dispatch, getState) => {
const { token } = getActiveAccount(getState()) || {};
try {
const SAFETY_FACTOR = 300; // ask new token earlier to overcome time desynchronization problem
const { exp } = getJwtPayloads(token as any);
try {
const SAFETY_FACTOR = 300; // ask new token earlier to overcome time desynchronization problem
const { exp } = getJwtPayloads(token as any);
if (exp - SAFETY_FACTOR < Date.now() / 1000) {
return dispatch(requestNewToken());
}
} catch (err) {
logger.warn('Refresh token error: bad token', {
token,
});
if (exp - SAFETY_FACTOR < Date.now() / 1000) {
return dispatch(requestNewToken());
}
} catch (err) {
logger.warn('Refresh token error: bad token', {
token,
});
dispatch(relogin());
dispatch(relogin());
return Promise.reject(new Error('Invalid token'));
}
return Promise.reject(new Error('Invalid token'));
}
return Promise.resolve();
};
return Promise.resolve();
};
}
/**
@ -193,40 +178,38 @@ export function ensureToken(): ThunkAction<Promise<void>> {
* @returns {Function}
*/
export function recoverFromTokenError(
error: {
status: number;
message: string;
} | void,
error: {
status: number;
message: string;
} | void,
): ThunkAction<Promise<void>> {
return (dispatch, getState) => {
if (error && error.status === 401) {
const activeAccount = getActiveAccount(getState());
return (dispatch, getState) => {
if (error && error.status === 401) {
const activeAccount = getActiveAccount(getState());
if (activeAccount && activeAccount.refreshToken) {
if (
[
'Token expired',
'Incorrect token',
'You are requesting with an invalid credential.',
].includes(error.message)
) {
// request token and retry
return dispatch(requestNewToken());
if (activeAccount && activeAccount.refreshToken) {
if (
['Token expired', 'Incorrect token', 'You are requesting with an invalid credential.'].includes(
error.message,
)
) {
// request token and retry
return dispatch(requestNewToken());
}
logger.error('Unknown unauthorized response', {
error,
});
}
// user's access token is outdated and we have no refreshToken
// or something unexpected happend
// in both cases we resetting all the user's state
dispatch(relogin());
}
logger.error('Unknown unauthorized response', {
error,
});
}
// user's access token is outdated and we have no refreshToken
// or something unexpected happend
// in both cases we resetting all the user's state
dispatch(relogin());
}
return Promise.reject(error);
};
return Promise.reject(error);
};
}
/**
@ -236,28 +219,28 @@ export function recoverFromTokenError(
* @returns {Function}
*/
export function requestNewToken(): ThunkAction<Promise<void>> {
return (dispatch, getState) => {
const { refreshToken } = getActiveAccount(getState()) || {};
return (dispatch, getState) => {
const { refreshToken } = getActiveAccount(getState()) || {};
if (!refreshToken) {
dispatch(relogin());
if (!refreshToken) {
dispatch(relogin());
return Promise.resolve();
}
return Promise.resolve();
}
return requestToken(refreshToken)
.then((token) => {
dispatch(updateToken(token));
})
.catch((resp) => {
// all the logic to get the valid token was failed,
// looks like we have some problems with token
// lets redirect to login page
dispatch(relogin());
return requestToken(refreshToken)
.then((token) => {
dispatch(updateToken(token));
})
.catch((resp) => {
// all the logic to get the valid token was failed,
// looks like we have some problems with token
// lets redirect to login page
dispatch(relogin());
return Promise.reject(resp);
});
};
return Promise.reject(resp);
});
};
}
/**
@ -268,62 +251,62 @@ export function requestNewToken(): ThunkAction<Promise<void>> {
* @returns {Function}
*/
export function revoke(account: Account): ThunkAction<Promise<void>> {
return async (dispatch, getState) => {
const accountToReplace: Account | null =
getState().accounts.available.find(({ id }) => id !== account.id) || null;
return async (dispatch, getState) => {
const accountToReplace: Account | null =
getState().accounts.available.find(({ id }) => id !== account.id) || null;
if (accountToReplace) {
await dispatch(authenticate(accountToReplace))
.finally(() => {
// we need to logout user, even in case, when we can
// not authenticate him with new account
logout(account.token).catch(() => {
// we don't care
});
dispatch(remove(account));
})
.catch(() => {
// we don't care
});
if (accountToReplace) {
await dispatch(authenticate(accountToReplace))
.finally(() => {
// we need to logout user, even in case, when we can
// not authenticate him with new account
logout(account.token).catch(() => {
// we don't care
});
dispatch(remove(account));
})
.catch(() => {
// we don't care
});
return;
}
return;
}
return dispatch(logoutAll());
};
return dispatch(logoutAll());
};
}
export function relogin(email?: string): ThunkAction<Promise<void>> {
return async (dispatch, getState) => {
const activeAccount = getActiveAccount(getState());
return async (dispatch, getState) => {
const activeAccount = getActiveAccount(getState());
if (!email && activeAccount) {
email = activeAccount.email;
}
if (!email && activeAccount) {
email = activeAccount.email;
}
dispatch(navigateToLogin(email || null));
};
dispatch(navigateToLogin(email || null));
};
}
export function logoutAll(): ThunkAction<Promise<void>> {
return (dispatch, getState) => {
dispatch(setGuest());
return (dispatch, getState) => {
dispatch(setGuest());
const {
accounts: { available },
} = getState();
const {
accounts: { available },
} = getState();
available.forEach((account) =>
logout(account.token).catch(() => {
// we don't care
}),
);
available.forEach((account) =>
logout(account.token).catch(() => {
// we don't care
}),
);
dispatch(reset());
dispatch(relogin());
dispatch(reset());
dispatch(relogin());
return Promise.resolve();
};
return Promise.resolve();
};
}
/**
@ -335,38 +318,35 @@ export function logoutAll(): ThunkAction<Promise<void>> {
* @returns {Function}
*/
export function logoutStrangers(): ThunkAction<Promise<void>> {
return async (dispatch, getState) => {
const {
accounts: { available },
} = getState();
const activeAccount = getActiveAccount(getState());
return async (dispatch, getState) => {
const {
accounts: { available },
} = getState();
const activeAccount = getActiveAccount(getState());
const isStranger = ({ refreshToken, id }: Account) =>
!refreshToken && !sessionStorage.getItem(`stranger${id}`);
const isStranger = ({ refreshToken, id }: Account) => !refreshToken && !sessionStorage.getItem(`stranger${id}`);
if (available.some(isStranger)) {
const accountToReplace = available.find(
(account) => !isStranger(account),
);
if (available.some(isStranger)) {
const accountToReplace = available.find((account) => !isStranger(account));
if (accountToReplace) {
available.filter(isStranger).forEach((account) => {
dispatch(remove(account));
logout(account.token);
});
if (accountToReplace) {
available.filter(isStranger).forEach((account) => {
dispatch(remove(account));
logout(account.token);
});
if (activeAccount && isStranger(activeAccount)) {
await dispatch(authenticate(accountToReplace));
if (activeAccount && isStranger(activeAccount)) {
await dispatch(authenticate(accountToReplace));
return;
return;
}
} else {
await dispatch(logoutAll());
return;
}
}
} else {
await dispatch(logoutAll());
return;
}
}
return Promise.resolve();
};
return Promise.resolve();
};
}

View File

@ -2,66 +2,61 @@ import { Action as ReduxAction } from 'redux';
import { Account } from 'app/components/accounts/reducer';
interface AddAction extends ReduxAction {
type: 'accounts:add';
payload: Account;
type: 'accounts:add';
payload: Account;
}
export function add(account: Account): AddAction {
return {
type: 'accounts:add',
payload: account,
};
return {
type: 'accounts:add',
payload: account,
};
}
interface RemoveAction extends ReduxAction {
type: 'accounts:remove';
payload: Account;
type: 'accounts:remove';
payload: Account;
}
export function remove(account: Account): RemoveAction {
return {
type: 'accounts:remove',
payload: account,
};
return {
type: 'accounts:remove',
payload: account,
};
}
interface ActivateAction extends ReduxAction {
type: 'accounts:activate';
payload: Account;
type: 'accounts:activate';
payload: Account;
}
export function activate(account: Account): ActivateAction {
return {
type: 'accounts:activate',
payload: account,
};
return {
type: 'accounts:activate',
payload: account,
};
}
interface ResetAction extends ReduxAction {
type: 'accounts:reset';
type: 'accounts:reset';
}
export function reset(): ResetAction {
return {
type: 'accounts:reset',
};
return {
type: 'accounts:reset',
};
}
interface UpdateTokenAction extends ReduxAction {
type: 'accounts:updateToken';
payload: string;
type: 'accounts:updateToken';
payload: string;
}
export function updateToken(token: string): UpdateTokenAction {
return {
type: 'accounts:updateToken',
payload: token,
};
return {
type: 'accounts:updateToken',
payload: token,
};
}
export type Action =
| AddAction
| RemoveAction
| ActivateAction
| ResetAction
| UpdateTokenAction;
export type Action = AddAction | RemoveAction | ActivateAction | ResetAction | UpdateTokenAction;

View File

@ -6,148 +6,122 @@ import { AccountsState } from './index';
import accounts, { Account } from './reducer';
const account: Account = {
id: 1,
username: 'username',
email: 'email@test.com',
token: 'foo',
id: 1,
username: 'username',
email: 'email@test.com',
token: 'foo',
} as Account;
describe('Accounts reducer', () => {
let initial: AccountsState;
let initial: AccountsState;
beforeEach(() => {
initial = accounts(undefined, {} as any);
});
it('should be empty', () =>
expect(accounts(undefined, {} as any), 'to equal', {
active: null,
available: [],
}));
it('should return last state if unsupported action', () =>
expect(accounts({ state: 'foo' } as any, {} as any), 'to equal', {
state: 'foo',
}));
describe('accounts:activate', () => {
it('sets active account', () => {
expect(accounts(initial, activate(account)), 'to satisfy', {
active: account.id,
});
});
});
describe('accounts:add', () => {
it('adds an account', () =>
expect(accounts(initial, add(account)), 'to satisfy', {
available: [account],
}));
it('should replace if account was added for the second time', () => {
const outdatedAccount = {
...account,
someShit: true,
};
const updatedAccount = {
...account,
token: 'newToken',
};
expect(
accounts(
{ ...initial, available: [outdatedAccount] },
add(updatedAccount),
),
'to satisfy',
{
available: [updatedAccount],
},
);
beforeEach(() => {
initial = accounts(undefined, {} as any);
});
it('should sort accounts by username', () => {
const newAccount = {
...account,
id: 2,
username: 'abc',
};
it('should be empty', () =>
expect(accounts(undefined, {} as any), 'to equal', {
active: null,
available: [],
}));
expect(
accounts({ ...initial, available: [account] }, add(newAccount)),
'to satisfy',
{
available: [newAccount, account],
},
);
it('should return last state if unsupported action', () =>
expect(accounts({ state: 'foo' } as any, {} as any), 'to equal', {
state: 'foo',
}));
describe('accounts:activate', () => {
it('sets active account', () => {
expect(accounts(initial, activate(account)), 'to satisfy', {
active: account.id,
});
});
});
it('throws, when account is invalid', () => {
expect(
() =>
accounts(
initial,
// @ts-ignore
add(),
),
'to throw',
'Invalid or empty payload passed for accounts.add',
);
describe('accounts:add', () => {
it('adds an account', () =>
expect(accounts(initial, add(account)), 'to satisfy', {
available: [account],
}));
it('should replace if account was added for the second time', () => {
const outdatedAccount = {
...account,
someShit: true,
};
const updatedAccount = {
...account,
token: 'newToken',
};
expect(accounts({ ...initial, available: [outdatedAccount] }, add(updatedAccount)), 'to satisfy', {
available: [updatedAccount],
});
});
it('should sort accounts by username', () => {
const newAccount = {
...account,
id: 2,
username: 'abc',
};
expect(accounts({ ...initial, available: [account] }, add(newAccount)), 'to satisfy', {
available: [newAccount, account],
});
});
it('throws, when account is invalid', () => {
expect(
() =>
accounts(
initial,
// @ts-ignore
add(),
),
'to throw',
'Invalid or empty payload passed for accounts.add',
);
});
});
});
describe('accounts:remove', () => {
it('should remove an account', () =>
expect(
accounts({ ...initial, available: [account] }, remove(account)),
'to equal',
initial,
));
describe('accounts:remove', () => {
it('should remove an account', () =>
expect(accounts({ ...initial, available: [account] }, remove(account)), 'to equal', initial));
it('throws, when account is invalid', () => {
expect(
() =>
accounts(
initial,
// @ts-ignore
remove(),
),
'to throw',
'Invalid or empty payload passed for accounts.remove',
);
it('throws, when account is invalid', () => {
expect(
() =>
accounts(
initial,
// @ts-ignore
remove(),
),
'to throw',
'Invalid or empty payload passed for accounts.remove',
);
});
});
});
describe('actions:reset', () => {
it('should reset accounts state', () =>
expect(
accounts({ ...initial, available: [account] }, reset()),
'to equal',
initial,
));
});
describe('accounts:updateToken', () => {
it('should update token', () => {
const newToken = 'newToken';
expect(
accounts(
{ active: account.id, available: [account] },
updateToken(newToken),
),
'to satisfy',
{
active: account.id,
available: [
{
...account,
token: newToken,
},
],
},
);
describe('actions:reset', () => {
it('should reset accounts state', () =>
expect(accounts({ ...initial, available: [account] }, reset()), 'to equal', initial));
});
describe('accounts:updateToken', () => {
it('should update token', () => {
const newToken = 'newToken';
expect(accounts({ active: account.id, available: [account] }, updateToken(newToken)), 'to satisfy', {
active: account.id,
available: [
{
...account,
token: newToken,
},
],
});
});
});
});
});

View File

@ -1,124 +1,116 @@
import { Action } from './actions/pure-actions';
export type Account = {
id: number;
username: string;
email: string;
token: string;
refreshToken: string | null;
id: number;
username: string;
email: string;
token: string;
refreshToken: string | null;
};
export type State = {
active: number | null;
available: Array<Account>;
active: number | null;
available: Array<Account>;
};
export function getActiveAccount(state: { accounts: State }): Account | null {
const accountId = state.accounts.active;
const accountId = state.accounts.active;
return (
state.accounts.available.find((account) => account.id === accountId) || null
);
return state.accounts.available.find((account) => account.id === accountId) || null;
}
export function getAvailableAccounts(state: {
accounts: State;
}): Array<Account> {
return state.accounts.available;
export function getAvailableAccounts(state: { accounts: State }): Array<Account> {
return state.accounts.available;
}
export default function accounts(
state: State = {
active: null,
available: [],
},
action: Action,
): State {
switch (action.type) {
case 'accounts:add': {
if (!action.payload || !action.payload.id || !action.payload.token) {
throw new Error('Invalid or empty payload passed for accounts.add');
}
const { payload } = action;
state.available = state.available
.filter((account) => account.id !== payload.id)
.concat(payload);
state.available.sort((account1, account2) => {
if (account1.username === account2.username) {
return 0;
}
return account1.username > account2.username ? 1 : -1;
});
return state;
}
case 'accounts:activate': {
if (!action.payload || !action.payload.id || !action.payload.token) {
throw new Error('Invalid or empty payload passed for accounts.add');
}
const { payload } = action;
return {
available: state.available.map((account) => {
if (account.id === payload.id) {
return { ...payload };
}
return { ...account };
}),
active: payload.id,
};
}
case 'accounts:reset':
return {
state: State = {
active: null,
available: [],
};
},
action: Action,
): State {
switch (action.type) {
case 'accounts:add': {
if (!action.payload || !action.payload.id || !action.payload.token) {
throw new Error('Invalid or empty payload passed for accounts.add');
}
case 'accounts:remove': {
if (!action.payload || !action.payload.id) {
throw new Error('Invalid or empty payload passed for accounts.remove');
}
const { payload } = action;
const { payload } = action;
state.available = state.available.filter((account) => account.id !== payload.id).concat(payload);
return {
...state,
available: state.available.filter(
(account) => account.id !== payload.id,
),
};
}
state.available.sort((account1, account2) => {
if (account1.username === account2.username) {
return 0;
}
case 'accounts:updateToken': {
if (typeof action.payload !== 'string') {
throw new Error('payload must be a jwt token');
}
return account1.username > account2.username ? 1 : -1;
});
const { payload } = action;
return state;
}
case 'accounts:activate': {
if (!action.payload || !action.payload.id || !action.payload.token) {
throw new Error('Invalid or empty payload passed for accounts.add');
}
const { payload } = action;
return {
...state,
available: state.available.map((account) => {
if (account.id === state.active) {
return {
...account,
token: payload,
available: state.available.map((account) => {
if (account.id === payload.id) {
return { ...payload };
}
return { ...account };
}),
active: payload.id,
};
}
}
return { ...account };
}),
};
case 'accounts:reset':
return {
active: null,
available: [],
};
case 'accounts:remove': {
if (!action.payload || !action.payload.id) {
throw new Error('Invalid or empty payload passed for accounts.remove');
}
const { payload } = action;
return {
...state,
available: state.available.filter((account) => account.id !== payload.id),
};
}
case 'accounts:updateToken': {
if (typeof action.payload !== 'string') {
throw new Error('payload must be a jwt token');
}
const { payload } = action;
return {
...state,
available: state.available.map((account) => {
if (account.id === state.active) {
return {
...account,
token: payload,
};
}
return { ...account };
}),
};
}
}
}
return state;
return state;
}

View File

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

View File

@ -11,63 +11,63 @@ import Context, { AuthContext } from './Context';
*/
class BaseAuthBody extends React.Component<
// TODO: this may be converted to generic type RouteComponentProps<T>
RouteComponentProps<Record<string, any>>
// TODO: this may be converted to generic type RouteComponentProps<T>
RouteComponentProps<Record<string, any>>
> {
static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>;
prevErrors: AuthContext['auth']['error'];
static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>;
prevErrors: AuthContext['auth']['error'];
autoFocusField: string | null = '';
autoFocusField: string | null = '';
componentDidMount() {
this.prevErrors = this.context.auth.error;
}
componentDidUpdate() {
if (this.context.auth.error !== this.prevErrors) {
this.form.setErrors(this.context.auth.error || {});
this.context.requestRedraw();
componentDidMount() {
this.prevErrors = this.context.auth.error;
}
this.prevErrors = this.context.auth.error;
}
componentDidUpdate() {
if (this.context.auth.error !== this.prevErrors) {
this.form.setErrors(this.context.auth.error || {});
this.context.requestRedraw();
}
renderErrors(): ReactNode {
const error = this.form.getFirstError();
if (error === null) {
return null;
this.prevErrors = this.context.auth.error;
}
return <AuthError error={error} onClose={this.onClearErrors} />;
}
renderErrors(): ReactNode {
const error = this.form.getFirstError();
onFormSubmit() {
this.context.resolve(this.serialize());
}
if (error === null) {
return null;
}
onClearErrors = () => this.context.clearErrors();
form = new FormModel({
renderErrors: false,
});
bindField(name: string) {
return this.form.bindField(name);
}
serialize() {
return this.form.serialize();
}
autoFocus() {
const fieldId = this.autoFocusField;
if (fieldId && this.form.hasField(fieldId)) {
this.form.focus(fieldId);
return <AuthError error={error} onClose={this.onClearErrors} />;
}
onFormSubmit() {
this.context.resolve(this.serialize());
}
onClearErrors = () => this.context.clearErrors();
form = new FormModel({
renderErrors: false,
});
bindField(name: string) {
return this.form.bindField(name);
}
serialize() {
return this.form.serialize();
}
autoFocus() {
const fieldId = this.autoFocusField;
if (fieldId && this.form.hasField(fieldId)) {
this.form.focus(fieldId);
}
}
}
}
export default BaseAuthBody;

View File

@ -4,28 +4,28 @@ import { User } from 'app/components/user';
import { State as AuthState } from './reducer';
export interface AuthContext {
auth: AuthState;
user: User;
requestRedraw: () => Promise<void>;
clearErrors: () => void;
resolve: (payload: { [key: string]: any } | undefined) => void;
reject: (payload: { [key: string]: any } | undefined) => void;
auth: AuthState;
user: User;
requestRedraw: () => Promise<void>;
clearErrors: () => void;
resolve: (payload: { [key: string]: any } | undefined) => void;
reject: (payload: { [key: string]: any } | undefined) => void;
}
const Context = React.createContext<AuthContext>({
auth: {
error: null,
login: '',
scopes: [],
} as any,
user: {
id: null,
isGuest: true,
} as any,
async requestRedraw() {},
clearErrors() {},
resolve() {},
reject() {},
auth: {
error: null,
login: '',
scopes: [],
} as any,
user: {
id: null,
isGuest: true,
} as any,
async requestRedraw() {},
clearErrors() {},
resolve() {},
reject() {},
});
Context.displayName = 'AuthContext';

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -4,36 +4,32 @@ import { FormattedMessage as Message, MessageDescriptor } from 'react-intl';
import Context, { AuthContext } from './Context';
interface Props {
isAvailable?: (context: AuthContext) => boolean;
payload?: Record<string, any>;
label: MessageDescriptor;
isAvailable?: (context: AuthContext) => boolean;
payload?: Record<string, any>;
label: MessageDescriptor;
}
const RejectionLink: ComponentType<Props> = ({
isAvailable,
payload,
label,
}) => {
const context = useContext(Context);
const RejectionLink: ComponentType<Props> = ({ isAvailable, payload, label }) => {
const context = useContext(Context);
if (isAvailable && !isAvailable(context)) {
// TODO: if want to properly support multiple links, we should control
// the dividers ' | ' rendered from factory too
return null;
}
if (isAvailable && !isAvailable(context)) {
// TODO: if want to properly support multiple links, we should control
// the dividers ' | ' rendered from factory too
return null;
}
return (
<a
href="#"
onClick={(event) => {
event.preventDefault();
return (
<a
href="#"
onClick={(event) => {
event.preventDefault();
context.reject(payload);
}}
>
<Message {...label} />
</a>
);
context.reject(payload);
}}
>
<Message {...label} />
</a>
);
};
export default RejectionLink;

View File

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

View File

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

View File

@ -11,38 +11,38 @@ import styles from './acceptRules.scss';
import messages from './AcceptRules.intl.json';
export default class AcceptRulesBody extends BaseAuthBody {
static displayName = 'AcceptRulesBody';
static panelId = 'acceptRules';
static displayName = 'AcceptRulesBody';
static panelId = 'acceptRules';
render() {
return (
<div>
{this.renderErrors()}
render() {
return (
<div>
{this.renderErrors()}
<div className={styles.security}>
<span className={icons.lock} />
</div>
<div className={styles.security}>
<span className={icons.lock} />
</div>
<p className={styles.descriptionText}>
<Message
{...messages.description1}
values={{
link: (
<Link to="/rules" target="_blank">
<Message {...messages.termsOfService} />
</Link>
),
}}
/>
<br />
<Message
{...messages.description2}
values={{
name: <Message {...appInfo.appName} />,
}}
/>
</p>
</div>
);
}
<p className={styles.descriptionText}>
<Message
{...messages.description1}
values={{
link: (
<Link to="/rules" target="_blank">
<Message {...messages.termsOfService} />
</Link>
),
}}
/>
<br />
<Message
{...messages.description2}
values={{
name: <Message {...appInfo.appName} />,
}}
/>
</p>
</div>
);
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -9,49 +9,48 @@ import styles from './activation.scss';
import messages from './Activation.intl.json';
export default class ActivationBody extends BaseAuthBody {
static displayName = 'ActivationBody';
static panelId = 'activation';
static displayName = 'ActivationBody';
static panelId = 'activation';
autoFocusField =
this.props.match.params && this.props.match.params.key ? null : 'key';
autoFocusField = this.props.match.params && this.props.match.params.key ? null : 'key';
render() {
const { key } = this.props.match.params;
const { email } = this.context.user;
render() {
const { key } = this.props.match.params;
const { email } = this.context.user;
return (
<div>
{this.renderErrors()}
return (
<div>
{this.renderErrors()}
<div className={styles.description}>
<div className={styles.descriptionImage} />
<div className={styles.description}>
<div className={styles.descriptionImage} />
<div className={styles.descriptionText}>
{email ? (
<Message
{...messages.activationMailWasSent}
values={{
email: <b>{email}</b>,
}}
/>
) : (
<Message {...messages.activationMailWasSentNoEmail} />
)}
</div>
</div>
<div className={styles.formRow}>
<Input
{...this.bindField('key')}
color="blue"
center
required
value={key}
readOnly={!!key}
autoComplete="off"
placeholder={messages.enterTheCode}
/>
</div>
</div>
);
}
<div className={styles.descriptionText}>
{email ? (
<Message
{...messages.activationMailWasSent}
values={{
email: <b>{email}</b>,
}}
/>
) : (
<Message {...messages.activationMailWasSentNoEmail} />
)}
</div>
</div>
<div className={styles.formRow}>
<Input
{...this.bindField('key')}
color="blue"
center
required
value={key}
readOnly={!!key}
autoComplete="off"
placeholder={messages.enterTheCode}
/>
</div>
</div>
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,41 +5,36 @@ import { PanelBodyHeader } from 'app/components/ui/Panel';
import { ValidationError } from 'app/components/ui/form/FormModel';
interface Props {
error: ValidationError;
onClose?: () => void;
error: ValidationError;
onClose?: () => void;
}
let autoHideTimer: number | null = null;
function resetTimeout(): void {
if (autoHideTimer) {
clearTimeout(autoHideTimer);
autoHideTimer = null;
}
if (autoHideTimer) {
clearTimeout(autoHideTimer);
autoHideTimer = null;
}
}
const AuthError: ComponentType<Props> = ({ error, onClose }) => {
useEffect(() => {
resetTimeout();
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);
}
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 resetTimeout;
}, [error, onClose]);
return (
<PanelBodyHeader type="error" onClose={onClose}>
{resolveError(error)}
</PanelBodyHeader>
);
return (
<PanelBodyHeader type="error" onClose={onClose}>
{resolveError(error)}
</PanelBodyHeader>
);
};
export default AuthError;

View File

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

View File

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

View File

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

View File

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

View File

@ -7,44 +7,36 @@ import { Color } from 'app/components/ui';
import BaseAuthBody from './BaseAuthBody';
export type Factory = () => {
Title: ComponentType;
Body: typeof BaseAuthBody;
Footer: ComponentType;
Links: ComponentType;
Title: ComponentType;
Body: typeof BaseAuthBody;
Footer: ComponentType;
Links: ComponentType;
};
type RejectionLinkProps = ComponentProps<typeof RejectionLink>;
interface FactoryParams {
title: MessageDescriptor;
body: typeof BaseAuthBody;
footer: {
color?: Color;
label: string | MessageDescriptor;
autoFocus?: boolean;
};
links?: RejectionLinkProps | Array<RejectionLinkProps>;
title: MessageDescriptor;
body: typeof BaseAuthBody;
footer: {
color?: Color;
label: string | MessageDescriptor;
autoFocus?: boolean;
};
links?: RejectionLinkProps | Array<RejectionLinkProps>;
}
export default function ({
title,
body,
footer,
links,
}: FactoryParams): Factory {
return () => ({
Title: () => <AuthTitle title={title} />,
Body: body,
Footer: () => <Button type="submit" {...footer} />,
Links: () =>
links ? (
<span>
{([] as Array<RejectionLinkProps>)
.concat(links)
.map((link, index) => [
index ? ' | ' : '',
<RejectionLink {...link} key={index} />,
])}
</span>
) : null,
});
export default function ({ title, body, footer, links }: FactoryParams): Factory {
return () => ({
Title: () => <AuthTitle title={title} />,
Body: body,
Footer: () => <Button type="submit" {...footer} />,
Links: () =>
links ? (
<span>
{([] as Array<RejectionLinkProps>)
.concat(links)
.map((link, index) => [index ? ' | ' : '', <RejectionLink {...link} key={index} />])}
</span>
) : null,
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -11,87 +11,83 @@ import styles from './forgotPassword.scss';
import messages from './ForgotPassword.intl.json';
export default class ForgotPasswordBody extends BaseAuthBody {
static displayName = 'ForgotPasswordBody';
static panelId = 'forgotPassword';
static hasGoBack = true;
static displayName = 'ForgotPasswordBody';
static panelId = 'forgotPassword';
static hasGoBack = true;
state = {
isLoginEdit: false,
};
state = {
isLoginEdit: false,
};
autoFocusField = 'login';
autoFocusField = 'login';
render() {
const { isLoginEdit } = this.state;
render() {
const { isLoginEdit } = this.state;
const login = this.getLogin();
const isLoginEditShown = isLoginEdit || !login;
const login = this.getLogin();
const isLoginEditShown = isLoginEdit || !login;
return (
<div>
{this.renderErrors()}
return (
<div>
{this.renderErrors()}
<PanelIcon icon="lock" />
<PanelIcon icon="lock" />
{isLoginEditShown ? (
<div>
<p className={styles.descriptionText}>
<Message {...messages.specifyEmail} />
</p>
<Input
{...this.bindField('login')}
icon="envelope"
color="lightViolet"
required
placeholder={messages.accountEmail}
defaultValue={login}
/>
</div>
) : (
<div data-testid="forgot-password-login">
<div className={styles.login}>
{login}
<span
className={styles.editLogin}
onClick={this.onClickEdit}
data-testid="edit-login"
/>
{isLoginEditShown ? (
<div>
<p className={styles.descriptionText}>
<Message {...messages.specifyEmail} />
</p>
<Input
{...this.bindField('login')}
icon="envelope"
color="lightViolet"
required
placeholder={messages.accountEmail}
defaultValue={login}
/>
</div>
) : (
<div data-testid="forgot-password-login">
<div className={styles.login}>
{login}
<span className={styles.editLogin} onClick={this.onClickEdit} data-testid="edit-login" />
</div>
<p className={styles.descriptionText}>
<Message {...messages.pleasePressButton} />
</p>
</div>
)}
<Captcha {...this.bindField('captcha')} delay={600} />
</div>
<p className={styles.descriptionText}>
<Message {...messages.pleasePressButton} />
</p>
</div>
)}
<Captcha {...this.bindField('captcha')} delay={600} />
</div>
);
}
serialize() {
const data = super.serialize();
if (!data.login) {
data.login = this.getLogin();
);
}
return data;
}
serialize() {
const data = super.serialize();
getLogin() {
const login = getLogin(this.context);
const { user } = this.context;
if (!data.login) {
data.login = this.getLogin();
}
return login || user.username || user.email || '';
}
return data;
}
onClickEdit = async () => {
this.setState({
isLoginEdit: true,
});
getLogin() {
const login = getLogin(this.context);
const { user } = this.context;
await this.context.requestRedraw();
return login || user.username || user.email || '';
}
this.form.focus('login');
};
onClickEdit = async () => {
this.setState({
isLoginEdit: true,
});
await this.context.requestRedraw();
this.form.focus('login');
};
}

View File

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

View File

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

View File

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

View File

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

View File

@ -7,26 +7,21 @@ 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: { user: User }) => {
return !state.user.isGuest;
};
static displayName = 'LoginBody';
static panelId = 'login';
static hasGoBack = (state: { user: User }) => {
return !state.user.isGuest;
};
autoFocusField = 'login';
autoFocusField = 'login';
render() {
return (
<div>
{this.renderErrors()}
render() {
return (
<div>
{this.renderErrors()}
<Input
{...this.bindField('login')}
icon="envelope"
required
placeholder={messages.emailOrUsername}
/>
</div>
);
}
<Input {...this.bindField('login')} icon="envelope" required placeholder={messages.emailOrUsername} />
</div>
);
}
}

View File

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

View File

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

View File

@ -8,30 +8,30 @@ import styles from './mfa.scss';
import messages from './Mfa.intl.json';
export default class MfaBody extends BaseAuthBody {
static panelId = 'mfa';
static hasGoBack = true;
static panelId = 'mfa';
static hasGoBack = true;
autoFocusField = 'totp';
autoFocusField = 'totp';
render() {
return (
<div>
{this.renderErrors()}
render() {
return (
<div>
{this.renderErrors()}
<PanelIcon icon="lock" />
<PanelIcon icon="lock" />
<p className={styles.descriptionText}>
<Message {...messages.description} />
</p>
<p className={styles.descriptionText}>
<Message {...messages.description} />
</p>
<Input
{...this.bindField('totp')}
icon="key"
required
placeholder={messages.enterTotp}
autoComplete="off"
/>
</div>
);
}
<Input
{...this.bindField('totp')}
icon="key"
required
placeholder={messages.enterTotp}
autoComplete="off"
/>
</div>
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,173 +3,156 @@ import { RootState } from 'app/reducers';
import { Scope } from '../../services/api/oauth';
import {
ErrorAction,
CredentialsAction,
AccountSwitcherAction,
LoadingAction,
ClientAction,
OAuthAction,
ScopesAction,
ErrorAction,
CredentialsAction,
AccountSwitcherAction,
LoadingAction,
ClientAction,
OAuthAction,
ScopesAction,
} from './actions';
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;
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>;
}
string,
| string
| {
type: string;
payload: Record<string, any>;
}
> | null;
export interface Client {
id: string;
name: string;
description: string;
id: string;
name: string;
description: string;
}
export interface OAuthState {
clientId: string;
redirectUrl: string;
responseType: string;
description?: string;
scope: string;
prompt: string;
loginHint: string;
state: string;
success?: boolean;
code?: string;
displayCode?: boolean;
acceptRequired?: boolean;
clientId: string;
redirectUrl: string;
responseType: string;
description?: string;
scope: string;
prompt: string;
loginHint: string;
state: string;
success?: boolean;
code?: string;
displayCode?: boolean;
acceptRequired?: boolean;
}
type Scopes = Array<Scope>;
export interface State {
credentials: Credentials;
error: Error;
isLoading: boolean;
isSwitcherEnabled: boolean;
client: Client | null;
oauth: OAuthState | null;
scopes: Scopes;
credentials: Credentials;
error: Error;
isLoading: boolean;
isSwitcherEnabled: boolean;
client: Client | null;
oauth: OAuthState | null;
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,
};
const error: Reducer<State['error'], ErrorAction> = (state = null, { type, payload }) => {
if (type === 'auth:error') {
return payload;
}
return {};
}
return state;
return state;
};
const isSwitcherEnabled: Reducer<
State['isSwitcherEnabled'],
AccountSwitcherAction
> = (state = true, { type, payload }) => {
if (type === 'auth:setAccountSwitcher') {
return payload;
}
const credentials: Reducer<State['credentials'], CredentialsAction> = (state = {}, { type, payload }) => {
if (type === 'auth:setCredentials') {
if (payload) {
return {
...payload,
};
}
return state;
return {};
}
return state;
};
const isLoading: Reducer<State['isLoading'], LoadingAction> = (
state = false,
{ type, payload },
const isSwitcherEnabled: Reducer<State['isSwitcherEnabled'], AccountSwitcherAction> = (
state = true,
{ type, payload },
) => {
if (type === 'set_loading_state') {
return payload;
}
if (type === 'auth:setAccountSwitcher') {
return payload;
}
return state;
return state;
};
const client: Reducer<State['client'], ClientAction> = (
state = null,
{ type, payload },
) => {
if (type === 'set_client') {
return payload;
}
const isLoading: Reducer<State['isLoading'], LoadingAction> = (state = false, { type, payload }) => {
if (type === 'set_loading_state') {
return payload;
}
return state;
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;
}
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;
}
const scopes: Reducer<State['scopes'], ScopesAction> = (state = [], { type, payload }) => {
if (type === 'set_scopes') {
return payload;
}
return state;
return state;
};
export default combineReducers<State>({
credentials,
error,
isLoading,
isSwitcherEnabled,
client,
oauth,
scopes,
credentials,
error,
isLoading,
isSwitcherEnabled,
client,
oauth,
scopes,
});
export function getLogin(
state: RootState | Pick<RootState, 'auth'>,
): string | null {
return state.auth.credentials.login || null;
export function getLogin(state: RootState | Pick<RootState, 'auth'>): string | null {
return state.auth.credentials.login || null;
}
export function getCredentials(state: RootState): Credentials {
return state.auth.credentials;
return state.auth.credentials;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -9,162 +9,143 @@ import { TestContextProvider } from 'app/shell';
import { ContactForm } from './ContactForm';
beforeEach(() => {
sinon.stub(feedback, 'send').returns(Promise.resolve() as any);
sinon.stub(feedback, 'send').returns(Promise.resolve() as any);
});
afterEach(() => {
(feedback.send as any).restore();
(feedback.send as any).restore();
});
describe('ContactForm', () => {
it('should contain Form', () => {
const user = {} as User;
it('should contain Form', () => {
const user = {} as User;
render(
<TestContextProvider>
<ContactForm user={user} />
</TestContextProvider>,
);
render(
<TestContextProvider>
<ContactForm user={user} />
</TestContextProvider>,
);
expect(screen.getAllByRole('textbox').length, 'to be greater than', 1);
expect(screen.getAllByRole('textbox').length, 'to be greater than', 1);
expect(
screen.getByRole('button', { name: /Send/ }),
'to have property',
'type',
'submit',
);
expect(screen.getByRole('button', { name: /Send/ }), 'to have property', 'type', 'submit');
[
{
label: 'subject',
name: 'subject',
},
{
label: 'Email',
name: 'email',
},
{
label: 'message',
name: 'message',
},
].forEach((el) => {
expect(
screen.getByLabelText(el.label, { exact: false }),
'to have property',
'name',
el.name,
);
});
});
describe('when rendered with user', () => {
const user = {
email: 'foo@bar.com',
} as User;
it('should render email field with user email', () => {
render(
<TestContextProvider>
<ContactForm user={user} />
</TestContextProvider>,
);
expect(screen.getByDisplayValue(user.email), 'to be a', HTMLInputElement);
});
});
it('should submit and then hide form and display success message', async () => {
const user = {
email: 'foo@bar.com',
} as User;
render(
<TestContextProvider>
<ContactForm user={user} />
</TestContextProvider>,
);
fireEvent.change(screen.getByLabelText(/subject/i), {
target: {
value: 'subject',
},
[
{
label: 'subject',
name: 'subject',
},
{
label: 'Email',
name: 'email',
},
{
label: 'message',
name: 'message',
},
].forEach((el) => {
expect(screen.getByLabelText(el.label, { exact: false }), 'to have property', 'name', el.name);
});
});
fireEvent.change(screen.getByLabelText(/message/i), {
target: {
value: 'the message',
},
describe('when rendered with user', () => {
const user = {
email: 'foo@bar.com',
} as User;
it('should render email field with user email', () => {
render(
<TestContextProvider>
<ContactForm user={user} />
</TestContextProvider>,
);
expect(screen.getByDisplayValue(user.email), 'to be a', HTMLInputElement);
});
});
const button = screen.getByRole('button', { name: 'Send' });
it('should submit and then hide form and display success message', async () => {
const user = {
email: 'foo@bar.com',
} as User;
expect(button, 'to have property', 'disabled', false);
render(
<TestContextProvider>
<ContactForm user={user} />
</TestContextProvider>,
);
fireEvent.click(button);
fireEvent.change(screen.getByLabelText(/subject/i), {
target: {
value: 'subject',
},
});
expect(button, 'to have property', 'disabled', true);
expect(feedback.send, 'to have a call exhaustively satisfying', [
{
subject: 'subject',
email: user.email,
category: '',
message: 'the message',
},
]);
fireEvent.change(screen.getByLabelText(/message/i), {
target: {
value: 'the message',
},
});
await waitFor(() => {
expect(
screen.getByText('Your message was received', { exact: false }),
'to be a',
HTMLElement,
);
const button = screen.getByRole('button', { name: 'Send' });
expect(button, 'to have property', 'disabled', false);
fireEvent.click(button);
expect(button, 'to have property', 'disabled', true);
expect(feedback.send, 'to have a call exhaustively satisfying', [
{
subject: 'subject',
email: user.email,
category: '',
message: 'the message',
},
]);
await waitFor(() => {
expect(screen.getByText('Your message was received', { exact: false }), 'to be a', HTMLElement);
});
expect(screen.getByText(user.email), 'to be a', HTMLElement);
expect(screen.queryByRole('button', { name: /Send/ }), 'to be null');
});
expect(screen.getByText(user.email), 'to be a', HTMLElement);
it('should show validation messages', async () => {
const user = {
email: 'foo@bar.com',
} as User;
expect(screen.queryByRole('button', { name: /Send/ }), 'to be null');
});
(feedback.send as any).callsFake(() =>
Promise.reject({
success: false,
errors: { email: 'error.email_invalid' },
}),
);
it('should show validation messages', async () => {
const user = {
email: 'foo@bar.com',
} as User;
render(
<TestContextProvider>
<ContactForm user={user} />
</TestContextProvider>,
);
(feedback.send as any).callsFake(() =>
Promise.reject({
success: false,
errors: { email: 'error.email_invalid' },
}),
);
fireEvent.change(screen.getByLabelText(/subject/i), {
target: {
value: 'subject',
},
});
render(
<TestContextProvider>
<ContactForm user={user} />
</TestContextProvider>,
);
fireEvent.change(screen.getByLabelText(/message/i), {
target: {
value: 'the message',
},
});
fireEvent.change(screen.getByLabelText(/subject/i), {
target: {
value: 'subject',
},
fireEvent.click(screen.getByRole('button', { name: 'Send' }));
await waitFor(() => {
expect(screen.getByRole('alert'), 'to have property', 'innerHTML', 'Email is invalid');
});
});
fireEvent.change(screen.getByLabelText(/message/i), {
target: {
value: 'the message',
},
});
fireEvent.click(screen.getByRole('button', { name: 'Send' }));
await waitFor(() => {
expect(
screen.getByRole('alert'),
'to have property',
'innerHTML',
'Email is invalid',
);
});
});
});

View File

@ -2,14 +2,7 @@ import React from 'react';
import { connect } from 'react-redux';
import clsx from 'clsx';
import { FormattedMessage as Message } from 'react-intl';
import {
Input,
TextArea,
Button,
Form,
FormModel,
Dropdown,
} from 'app/components/ui/form';
import { Input, TextArea, Button, Form, FormModel, Dropdown } from 'app/components/ui/form';
import feedback from 'app/services/api/feedback';
import icons from 'app/components/ui/icons.scss';
import popupStyles from 'app/components/ui/popup/popup.scss';
@ -21,190 +14,170 @@ import styles from './contactForm.scss';
import messages from './contactForm.intl.json';
const CONTACT_CATEGORIES = {
// TODO: сюда позже проставить реальные id категорий с backend
0: <Message {...messages.cannotAccessMyAccount} />,
1: <Message {...messages.foundBugOnSite} />,
2: <Message {...messages.improvementsSuggestion} />,
3: <Message {...messages.integrationQuestion} />,
4: <Message {...messages.other} />,
// TODO: сюда позже проставить реальные id категорий с backend
0: <Message {...messages.cannotAccessMyAccount} />,
1: <Message {...messages.foundBugOnSite} />,
2: <Message {...messages.improvementsSuggestion} />,
3: <Message {...messages.integrationQuestion} />,
4: <Message {...messages.other} />,
};
export class ContactForm extends React.Component<
{
onClose: () => void;
user: User;
},
{
isLoading: boolean;
isSuccessfullySent: boolean;
lastEmail: string | null;
}
{
onClose: () => void;
user: User;
},
{
isLoading: boolean;
isSuccessfullySent: boolean;
lastEmail: string | null;
}
> {
static defaultProps = {
onClose() {},
};
static defaultProps = {
onClose() {},
};
state = {
isLoading: false,
isSuccessfullySent: false,
lastEmail: null,
};
state = {
isLoading: false,
isSuccessfullySent: false,
lastEmail: null,
};
form = new FormModel();
form = new FormModel();
render() {
const { isSuccessfullySent } = this.state || {};
const { onClose } = this.props;
render() {
const { isSuccessfullySent } = this.state || {};
const { onClose } = this.props;
return (
<div
data-testid="feedbackPopup"
className={
isSuccessfullySent ? styles.successState : styles.contactForm
}
>
<div className={popupStyles.popup}>
<div className={popupStyles.header}>
<h2 className={popupStyles.headerTitle}>
<Message {...messages.title} />
</h2>
<span
className={clsx(icons.close, popupStyles.close)}
onClick={onClose}
data-testid="feedback-popup-close"
/>
</div>
return (
<div data-testid="feedbackPopup" className={isSuccessfullySent ? styles.successState : styles.contactForm}>
<div className={popupStyles.popup}>
<div className={popupStyles.header}>
<h2 className={popupStyles.headerTitle}>
<Message {...messages.title} />
</h2>
<span
className={clsx(icons.close, popupStyles.close)}
onClick={onClose}
data-testid="feedback-popup-close"
/>
</div>
{isSuccessfullySent ? this.renderSuccess() : this.renderForm()}
</div>
</div>
);
}
renderForm() {
const { form } = this;
const { user } = this.props;
const { isLoading } = this.state;
return (
<Form form={form} onSubmit={this.onSubmit} isLoading={isLoading}>
<div className={popupStyles.body}>
<div className={styles.philosophicalThought}>
<Message {...messages.philosophicalThought} />
</div>
<div className={styles.formDisclaimer}>
<Message {...messages.disclaimer} />
<br />
</div>
<div className={styles.pairInputRow}>
<div className={styles.pairInput}>
<Input
{...form.bindField('subject')}
required
label={messages.subject}
skin="light"
/>
{isSuccessfullySent ? this.renderSuccess() : this.renderForm()}
</div>
</div>
<div className={styles.pairInput}>
<Input
{...form.bindField('email')}
required
label={messages.email}
type="email"
skin="light"
defaultValue={user.email}
/>
</div>
</div>
<div className={styles.formMargin}>
<Dropdown
{...form.bindField('category')}
label={messages.whichQuestion}
items={CONTACT_CATEGORIES}
block
/>
</div>
<TextArea
{...form.bindField('message')}
required
label={messages.message}
skin="light"
minRows={6}
maxRows={6}
/>
</div>
<div className={styles.footer}>
<Button
label={messages.send}
block
type="submit"
disabled={isLoading}
/>
</div>
</Form>
);
}
renderSuccess() {
const { lastEmail: email } = this.state;
const { onClose } = this.props;
return (
<div>
<div className={styles.successBody}>
<span className={styles.successIcon} />
<div className={styles.successDescription}>
<Message {...messages.youMessageReceived} />
</div>
<div className={styles.sentToEmail}>{email}</div>
</div>
<div className={styles.footer}>
<Button
label={messages.close}
block
onClick={onClose}
data-testid="feedback-popup-close-button"
/>
</div>
</div>
);
}
onSubmit = (): Promise<void> => {
if (this.state.isLoading) {
return Promise.resolve();
);
}
this.setState({ isLoading: true });
renderForm() {
const { form } = this;
const { user } = this.props;
const { isLoading } = this.state;
return feedback
.send(this.form.serialize())
.then(() =>
this.setState({
isSuccessfullySent: true,
lastEmail: this.form.value('email'),
}),
)
.catch((resp) => {
if (resp.errors) {
this.form.setErrors(resp.errors);
return (
<Form form={form} onSubmit={this.onSubmit} isLoading={isLoading}>
<div className={popupStyles.body}>
<div className={styles.philosophicalThought}>
<Message {...messages.philosophicalThought} />
</div>
return;
<div className={styles.formDisclaimer}>
<Message {...messages.disclaimer} />
<br />
</div>
<div className={styles.pairInputRow}>
<div className={styles.pairInput}>
<Input {...form.bindField('subject')} required label={messages.subject} skin="light" />
</div>
<div className={styles.pairInput}>
<Input
{...form.bindField('email')}
required
label={messages.email}
type="email"
skin="light"
defaultValue={user.email}
/>
</div>
</div>
<div className={styles.formMargin}>
<Dropdown
{...form.bindField('category')}
label={messages.whichQuestion}
items={CONTACT_CATEGORIES}
block
/>
</div>
<TextArea
{...form.bindField('message')}
required
label={messages.message}
skin="light"
minRows={6}
maxRows={6}
/>
</div>
<div className={styles.footer}>
<Button label={messages.send} block type="submit" disabled={isLoading} />
</div>
</Form>
);
}
renderSuccess() {
const { lastEmail: email } = this.state;
const { onClose } = this.props;
return (
<div>
<div className={styles.successBody}>
<span className={styles.successIcon} />
<div className={styles.successDescription}>
<Message {...messages.youMessageReceived} />
</div>
<div className={styles.sentToEmail}>{email}</div>
</div>
<div className={styles.footer}>
<Button label={messages.close} block onClick={onClose} data-testid="feedback-popup-close-button" />
</div>
</div>
);
}
onSubmit = (): Promise<void> => {
if (this.state.isLoading) {
return Promise.resolve();
}
logger.warn('Error sending feedback', resp);
})
.finally(() => this.setState({ isLoading: false }));
};
this.setState({ isLoading: true });
return feedback
.send(this.form.serialize())
.then(() =>
this.setState({
isSuccessfullySent: true,
lastEmail: this.form.value('email'),
}),
)
.catch((resp) => {
if (resp.errors) {
this.form.setErrors(resp.errors);
return;
}
logger.warn('Error sending feedback', resp);
})
.finally(() => this.setState({ isLoading: false }));
};
}
export default connect((state: RootState) => ({
user: state.user,
user: state.user,
}))(ContactForm);

View File

@ -4,24 +4,24 @@ import { create as createPopup } from 'app/components/ui/popup/actions';
import ContactForm from './ContactForm';
type Props = React.AnchorHTMLAttributes<HTMLAnchorElement> & {
createContactPopup: () => void;
createContactPopup: () => void;
};
function ContactLink({ createContactPopup, ...props }: Props) {
return (
<a
href="#"
data-e2e-button="feedbackPopup"
onClick={(event) => {
event.preventDefault();
return (
<a
href="#"
data-e2e-button="feedbackPopup"
onClick={(event) => {
event.preventDefault();
createContactPopup();
}}
{...props}
/>
);
createContactPopup();
}}
{...props}
/>
);
}
export default connect(null, {
createContactPopup: () => createPopup({ Popup: ContactForm }),
createContactPopup: () => createPopup({ Popup: ContactForm }),
})(ContactLink);

View File

@ -1,19 +1,19 @@
{
"title": "Feedback form",
"subject": "Subject",
"email": "Email",
"message": "Message",
"send": "Send",
"philosophicalThought": "Properly formulated question — half of the answer",
"disclaimer": "Please formulate your feedback providing as much useful information, as possible to help us understand your problem and solve it",
"whichQuestion": "What are you interested in?",
"title": "Feedback form",
"subject": "Subject",
"email": "Email",
"message": "Message",
"send": "Send",
"philosophicalThought": "Properly formulated question — half of the answer",
"disclaimer": "Please formulate your feedback providing as much useful information, as possible to help us understand your problem and solve it",
"whichQuestion": "What are you interested in?",
"cannotAccessMyAccount": "Can not access my account",
"foundBugOnSite": "I found a bug on the site",
"improvementsSuggestion": "I have a suggestion for improving the functional",
"integrationQuestion": "Service integration question",
"other": "Other",
"cannotAccessMyAccount": "Can not access my account",
"foundBugOnSite": "I found a bug on the site",
"improvementsSuggestion": "I have a suggestion for improving the functional",
"integrationQuestion": "Service integration question",
"other": "Other",
"youMessageReceived": "Your message was received. We will respond to you shortly. The answer will come to your Email:",
"close": "Close"
"youMessageReceived": "Your message was received. We will respond to you shortly. The answer will come to your Email:",
"close": "Close"
}

View File

@ -5,81 +5,81 @@
/* Form state */
.contactForm {
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
@include popupBounding(500px);
@include popupBounding(500px);
}
.philosophicalThought {
font-family: $font-family-title;
font-size: 19px;
color: $green;
text-align: center;
margin-bottom: 5px;
font-family: $font-family-title;
font-size: 19px;
color: $green;
text-align: center;
margin-bottom: 5px;
}
.formDisclaimer {
font-size: 12px;
line-height: 14px;
text-align: center;
max-width: 400px;
margin: 0 auto 10px;
font-size: 12px;
line-height: 14px;
text-align: center;
max-width: 400px;
margin: 0 auto 10px;
}
.pairInputRow {
display: flex;
margin-bottom: 10px;
display: flex;
margin-bottom: 10px;
}
.pairInput {
width: 50%;
width: 50%;
&:first-of-type {
margin-right: $popupPadding;
}
&:first-of-type {
margin-right: $popupPadding;
}
}
.formMargin {
margin-bottom: 20px;
margin-bottom: 20px;
}
/* Success State */
.successState {
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
composes: popupWrapper from '~app/components/ui/popup/popup.scss';
@include popupBounding(320px);
@include popupBounding(320px);
}
.successBody {
composes: body from '~app/components/ui/popup/popup.scss';
composes: body from '~app/components/ui/popup/popup.scss';
text-align: center;
text-align: center;
}
.successDescription {
@extend .formDisclaimer;
@extend .formDisclaimer;
margin-bottom: 15px;
margin-bottom: 15px;
}
.successIcon {
composes: checkmark from '~app/components/ui/icons.scss';
composes: checkmark from '~app/components/ui/icons.scss';
font-size: 90px;
color: #aaa;
margin-bottom: 20px;
line-height: 71px;
font-size: 90px;
color: #aaa;
margin-bottom: 20px;
line-height: 71px;
}
.sentToEmail {
font-family: $font-family-title;
color: #444;
font-size: 18px;
font-family: $font-family-title;
color: #444;
font-size: 18px;
}
/* Common */
.footer {
margin-top: 0;
margin-top: 0;
}

View File

@ -1,26 +1,26 @@
{
"accountsForDevelopers": "Ely.by Accounts for developers",
"accountsAllowsYouYoUseOauth2": "Ely.by Accounts service provides users with a quick and easy-to-use way to login to your site, launcher or Minecraft server via OAuth2 authorization protocol. You can find more information about integration with Ely.by Accounts in {ourDocumentation}.",
"ourDocumentation": "our documentation",
"ifYouHaveAnyTroubles": "If you are experiencing difficulties, you can always use {feedback}. We'll surely help you.",
"feedback": "feedback",
"weDontKnowAnythingAboutYou": "We don't know anything about you yet.",
"youMustAuthToBegin": "You have to authorize to start.",
"authorization": "Authorization",
"youDontHaveAnyApplication": "You don't have any app registered yet.",
"shallWeStart": "Shall we start?",
"addNew": "Add new",
"yourApplications": "Your applications:",
"countUsers": "{count, plural, =0 {No users} one {# user} other {# users}}",
"ifYouSuspectingThatSecretHasBeenCompromised": "If you are suspecting that your Client Secret has been compromised, then you may want to reset it value. It'll cause recall of the all \"access\" and \"refresh\" tokens that have been issued. You can also recall all issued tokens without changing Client Secret.",
"revokeAllTokens": "Revoke all tokens",
"resetClientSecret": "Reset Client Secret",
"delete": "Delete",
"editDescription": "{icon} Edit description",
"allRefreshTokensWillBecomeInvalid": "All \"refresh\" tokens will become invalid and after next authorization the user will get permissions prompt.",
"appAndAllTokenWillBeDeleted": "Application and all associated tokens will be deleted.",
"takeCareAccessTokensInvalidation": "Take care because \"access\" tokens won't be invalidated immediately.",
"cancel": "Cancel",
"continue": "Continue",
"performing": "Performing…"
"accountsForDevelopers": "Ely.by Accounts for developers",
"accountsAllowsYouYoUseOauth2": "Ely.by Accounts service provides users with a quick and easy-to-use way to login to your site, launcher or Minecraft server via OAuth2 authorization protocol. You can find more information about integration with Ely.by Accounts in {ourDocumentation}.",
"ourDocumentation": "our documentation",
"ifYouHaveAnyTroubles": "If you are experiencing difficulties, you can always use {feedback}. We'll surely help you.",
"feedback": "feedback",
"weDontKnowAnythingAboutYou": "We don't know anything about you yet.",
"youMustAuthToBegin": "You have to authorize to start.",
"authorization": "Authorization",
"youDontHaveAnyApplication": "You don't have any app registered yet.",
"shallWeStart": "Shall we start?",
"addNew": "Add new",
"yourApplications": "Your applications:",
"countUsers": "{count, plural, =0 {No users} one {# user} other {# users}}",
"ifYouSuspectingThatSecretHasBeenCompromised": "If you are suspecting that your Client Secret has been compromised, then you may want to reset it value. It'll cause recall of the all \"access\" and \"refresh\" tokens that have been issued. You can also recall all issued tokens without changing Client Secret.",
"revokeAllTokens": "Revoke all tokens",
"resetClientSecret": "Reset Client Secret",
"delete": "Delete",
"editDescription": "{icon} Edit description",
"allRefreshTokensWillBecomeInvalid": "All \"refresh\" tokens will become invalid and after next authorization the user will get permissions prompt.",
"appAndAllTokenWillBeDeleted": "Application and all associated tokens will be deleted.",
"takeCareAccessTokensInvalidation": "Take care because \"access\" tokens won't be invalidated immediately.",
"cancel": "Cancel",
"continue": "Continue",
"performing": "Performing…"
}

View File

@ -15,144 +15,133 @@ import toolsIcon from './icons/tools.svg';
import ApplicationsList from './list';
type Props = {
clientId: string | null;
resetClientId: () => void; // notify parent to remove clientId from current location.href
displayForGuest: boolean;
applications: Array<OauthAppResponse>;
isLoading: boolean;
deleteApp: (clientId: string) => Promise<any>;
resetApp: (clientId: string, resetClientSecret: boolean) => Promise<any>;
clientId: string | null;
resetClientId: () => void; // notify parent to remove clientId from current location.href
displayForGuest: boolean;
applications: Array<OauthAppResponse>;
isLoading: boolean;
deleteApp: (clientId: string) => Promise<any>;
resetApp: (clientId: string, resetClientSecret: boolean) => Promise<any>;
};
export default class ApplicationsIndex extends React.Component<Props> {
render() {
return (
<div className={styles.container}>
<div className={styles.welcomeContainer}>
<Message {...messages.accountsForDevelopers}>
{(pageTitle: string) => (
<h2 className={styles.welcomeTitle}>
<Helmet title={pageTitle} />
{pageTitle}
</h2>
)}
</Message>
<div className={styles.welcomeTitleDelimiter} />
<div className={styles.welcomeParagraph}>
<Message
{...messages.accountsAllowsYouYoUseOauth2}
values={{
ourDocumentation: (
<a href="https://docs.ely.by/en/oauth.html" target="_blank">
<Message {...messages.ourDocumentation} />
</a>
),
}}
/>
</div>
<div className={styles.welcomeParagraph}>
<Message
{...messages.ifYouHaveAnyTroubles}
values={{
feedback: (
<ContactLink>
<Message {...messages.feedback} />
</ContactLink>
),
}}
/>
</div>
</div>
render() {
return (
<div className={styles.container}>
<div className={styles.welcomeContainer}>
<Message {...messages.accountsForDevelopers}>
{(pageTitle: string) => (
<h2 className={styles.welcomeTitle}>
<Helmet title={pageTitle} />
{pageTitle}
</h2>
)}
</Message>
<div className={styles.welcomeTitleDelimiter} />
<div className={styles.welcomeParagraph}>
<Message
{...messages.accountsAllowsYouYoUseOauth2}
values={{
ourDocumentation: (
<a href="https://docs.ely.by/en/oauth.html" target="_blank">
<Message {...messages.ourDocumentation} />
</a>
),
}}
/>
</div>
<div className={styles.welcomeParagraph}>
<Message
{...messages.ifYouHaveAnyTroubles}
values={{
feedback: (
<ContactLink>
<Message {...messages.feedback} />
</ContactLink>
),
}}
/>
</div>
</div>
{this.getContent()}
</div>
);
}
getContent() {
const {
displayForGuest,
applications,
isLoading,
resetApp,
deleteApp,
clientId,
resetClientId,
} = this.props;
if (applications.length > 0) {
return (
<ApplicationsList
applications={applications}
resetApp={resetApp}
deleteApp={deleteApp}
clientId={clientId}
resetClientId={resetClientId}
/>
);
{this.getContent()}
</div>
);
}
if (displayForGuest) {
return <Guest />;
}
getContent() {
const { displayForGuest, applications, isLoading, resetApp, deleteApp, clientId, resetClientId } = this.props;
return <Loader noApps={!isLoading} />;
}
if (applications.length > 0) {
return (
<ApplicationsList
applications={applications}
resetApp={resetApp}
deleteApp={deleteApp}
clientId={clientId}
resetClientId={resetClientId}
/>
);
}
if (displayForGuest) {
return <Guest />;
}
return <Loader noApps={!isLoading} />;
}
}
function Loader({ noApps }: { noApps: boolean }) {
return (
<div className={styles.emptyState} data-e2e={noApps ? 'noApps' : 'loading'}>
<img
src={noApps ? cubeIcon : loadingCubeIcon}
className={styles.emptyStateIcon}
/>
return (
<div className={styles.emptyState} data-e2e={noApps ? 'noApps' : 'loading'}>
<img src={noApps ? cubeIcon : loadingCubeIcon} className={styles.emptyStateIcon} />
<div
className={clsx(styles.noAppsContainer, {
[styles.noAppsAnimating]: noApps,
})}
>
<div className={styles.emptyStateText}>
<div>
<Message {...messages.youDontHaveAnyApplication} />
</div>
<div>
<Message {...messages.shallWeStart} />
</div>
<div
className={clsx(styles.noAppsContainer, {
[styles.noAppsAnimating]: noApps,
})}
>
<div className={styles.emptyStateText}>
<div>
<Message {...messages.youDontHaveAnyApplication} />
</div>
<div>
<Message {...messages.shallWeStart} />
</div>
</div>
<LinkButton
to="/dev/applications/new"
data-e2e="newApp"
label={messages.addNew}
color={COLOR_GREEN}
className={styles.emptyStateActionButton}
/>
</div>
</div>
<LinkButton
to="/dev/applications/new"
data-e2e="newApp"
label={messages.addNew}
color={COLOR_GREEN}
className={styles.emptyStateActionButton}
/>
</div>
</div>
);
);
}
function Guest() {
return (
<div className={styles.emptyState}>
<img src={toolsIcon} className={styles.emptyStateIcon} />
<div className={styles.emptyStateText}>
<div>
<Message {...messages.weDontKnowAnythingAboutYou} />
</div>
<div>
<Message {...messages.youMustAuthToBegin} />
</div>
</div>
return (
<div className={styles.emptyState}>
<img src={toolsIcon} className={styles.emptyStateIcon} />
<div className={styles.emptyStateText}>
<div>
<Message {...messages.weDontKnowAnythingAboutYou} />
</div>
<div>
<Message {...messages.youMustAuthToBegin} />
</div>
</div>
<LinkButton
to="/login"
label={messages.authorization}
color={COLOR_BLUE}
className={styles.emptyStateActionButton}
/>
</div>
);
<LinkButton
to="/login"
label={messages.authorization}
color={COLOR_BLUE}
className={styles.emptyStateActionButton}
/>
</div>
);
}

View File

@ -7,94 +7,85 @@ import { ThunkAction } from 'app/reducers';
import { Apps } from './reducer';
interface SetAvailableAction extends ReduxAction {
type: 'apps:setAvailable';
payload: Array<OauthAppResponse>;
type: 'apps:setAvailable';
payload: Array<OauthAppResponse>;
}
export function setAppsList(apps: Array<OauthAppResponse>): SetAvailableAction {
return {
type: 'apps:setAvailable',
payload: apps,
};
return {
type: 'apps:setAvailable',
payload: apps,
};
}
export function getApp(
state: { apps: Apps },
clientId: string,
): OauthAppResponse | null {
return state.apps.available.find((app) => app.clientId === clientId) || null;
export function getApp(state: { apps: Apps }, clientId: string): OauthAppResponse | null {
return state.apps.available.find((app) => app.clientId === clientId) || null;
}
export function fetchApp(clientId: string): ThunkAction<Promise<void>> {
return async (dispatch) => {
const app = await oauth.getApp(clientId);
return async (dispatch) => {
const app = await oauth.getApp(clientId);
dispatch(addApp(app));
};
dispatch(addApp(app));
};
}
interface AddAppAction extends ReduxAction {
type: 'apps:addApp';
payload: OauthAppResponse;
type: 'apps:addApp';
payload: OauthAppResponse;
}
function addApp(app: OauthAppResponse): AddAppAction {
return {
type: 'apps:addApp',
payload: app,
};
return {
type: 'apps:addApp',
payload: app,
};
}
export function fetchAvailableApps() {
return async (
dispatch: Dispatch<any>,
getState: () => { user: User },
): Promise<void> => {
const { id } = getState().user;
return async (dispatch: Dispatch<any>, getState: () => { user: User }): Promise<void> => {
const { id } = getState().user;
if (!id) {
dispatch(setAppsList([]));
if (!id) {
dispatch(setAppsList([]));
return;
}
return;
}
const apps = await oauth.getAppsByUser(id);
const apps = await oauth.getAppsByUser(id);
dispatch(setAppsList(apps));
};
dispatch(setAppsList(apps));
};
}
export function deleteApp(clientId: string) {
return async (dispatch: Dispatch<any>): Promise<void> => {
await oauth.delete(clientId);
return async (dispatch: Dispatch<any>): Promise<void> => {
await oauth.delete(clientId);
dispatch(createDeleteAppAction(clientId));
};
dispatch(createDeleteAppAction(clientId));
};
}
interface DeleteAppAction extends ReduxAction {
type: 'apps:deleteApp';
payload: string;
type: 'apps:deleteApp';
payload: string;
}
function createDeleteAppAction(clientId: string): DeleteAppAction {
return {
type: 'apps:deleteApp',
payload: clientId,
};
return {
type: 'apps:deleteApp',
payload: clientId,
};
}
export function resetApp(
clientId: string,
resetSecret: boolean,
): ThunkAction<Promise<void>> {
return async (dispatch) => {
const { data: app } = await oauth.reset(clientId, resetSecret);
export function resetApp(clientId: string, resetSecret: boolean): ThunkAction<Promise<void>> {
return async (dispatch) => {
const { data: app } = await oauth.reset(clientId, resetSecret);
if (resetSecret) {
dispatch(addApp(app));
}
};
if (resetSecret) {
dispatch(addApp(app));
}
};
}
export type Action = SetAvailableAction | DeleteAppAction | AddAppAction;

View File

@ -1,20 +1,20 @@
{
"creatingApplication": "Creating an application",
"website": "Web site",
"minecraftServer": "Minecraft server",
"toDisplayRegistrationFormChooseType": "To display registration form for a new application choose necessary type.",
"applicationName": "Application name:",
"appDescriptionWillBeAlsoVisibleOnOauthPage": "Application's description will be displayed at the authorization page too. It isn't a required field. In authorization process the value may be overridden.",
"description": "Description:",
"websiteLinkWillBeUsedAsAdditionalId": "Site's link is optional, but it can be used as an additional identifier of the application.",
"websiteLink": "Website link:",
"redirectUriLimitsAllowableBaseAddress": "Redirection URI (redirectUri) determines a base address, that user will be allowed to be redirected to after authorization. In order to improve security it's better to use the whole path instead of just a domain name. For example: https://example.com/oauth/ely.",
"redirectUri": "Redirect URI:",
"createApplication": "Create application",
"serverName": "Server name:",
"ipAddressIsOptionButPreferable": "IP address is optional, but is very preferable. It might become handy in case of we suddenly decide to play on your server with the entire band (=",
"serverIp": "Server IP:",
"youCanAlsoSpecifyServerSite": "You also can specify either server's site URL or its community in a social network.",
"updatingApplication": "Updating an application",
"updateApplication": "Update application"
"creatingApplication": "Creating an application",
"website": "Web site",
"minecraftServer": "Minecraft server",
"toDisplayRegistrationFormChooseType": "To display registration form for a new application choose necessary type.",
"applicationName": "Application name:",
"appDescriptionWillBeAlsoVisibleOnOauthPage": "Application's description will be displayed at the authorization page too. It isn't a required field. In authorization process the value may be overridden.",
"description": "Description:",
"websiteLinkWillBeUsedAsAdditionalId": "Site's link is optional, but it can be used as an additional identifier of the application.",
"websiteLink": "Website link:",
"redirectUriLimitsAllowableBaseAddress": "Redirection URI (redirectUri) determines a base address, that user will be allowed to be redirected to after authorization. In order to improve security it's better to use the whole path instead of just a domain name. For example: https://example.com/oauth/ely.",
"redirectUri": "Redirect URI:",
"createApplication": "Create application",
"serverName": "Server name:",
"ipAddressIsOptionButPreferable": "IP address is optional, but is very preferable. It might become handy in case of we suddenly decide to play on your server with the entire band (=",
"serverIp": "Server IP:",
"youCanAlsoSpecifyServerSite": "You also can specify either server's site URL or its community in a social network.",
"updatingApplication": "Updating an application",
"updateApplication": "Update application"
}

Some files were not shown because too many files have changed in this diff Show More