Merge pull request #14 from elyby/deps-updates

Upgrade project
This commit is contained in:
ErickSkrauch
2020-01-09 13:32:12 +03:00
committed by GitHub
787 changed files with 44223 additions and 36234 deletions

View File

@@ -1,17 +0,0 @@
{
"presets": ["react", "flow", "es2015", "es2017", "stage-0"],
"plugins": [
["transform-runtime", {"polyfill": false}],
"transform-function-bind",
["react-intl", {"messagesDir": "./dist/messages/"}]
],
"env": {
"development": {
"presets": ["react-hmre"]
},
"test": {
// airbnb some how disables stage-0, so forcing it after airbnb
"presets": ["airbnb", "stage-0"]
}
}
}

2
.browserslistrc Normal file
View File

@@ -0,0 +1,2 @@
> 0.25%
not dead

View File

@@ -1,2 +1,2 @@
dist
build
node_modules

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2

10
.env.tpl Normal file
View File

@@ -0,0 +1,10 @@
SENTRY_CDN=https://<key>@sentry.io/<project>
CROWDIN_API_KEY=abc
GA_ID=UA-XXXXX-Y
API_HOST=https://dev.account.ely.by
VERSION=dev
ENVIRONMENT=dev

View File

@@ -1,2 +1,3 @@
flow-typed
tests-e2e
build
dll
node_modules

221
.eslintrc
View File

@@ -1,221 +0,0 @@
{
"parser": "babel-eslint",
"parserOptions": {
"ecmaVersion": 2017,
"sourceType": "module"
},
"plugins": [
"react",
"flowtype"
],
"env": {
"mocha": true, // needed for tests. Apply it globaly till eslint/selint#3611
"browser": true,
"commonjs": true,
"es6": true
},
"extends": [
"eslint:recommended",
"plugin:flowtype/recommended"
],
"settings": {
"react": {
"version": "detect"
}
},
// @see: http://eslint.org/docs/rules/
"rules": {
// possible errors (including eslint:recommended)
"valid-jsdoc": ["warn", {
"requireParamDescription": false,
"requireReturn": false,
"requireReturnDescription": false,
"prefer": {
"returns": "return"
},
"preferType": {
"String": "string",
"Object": "object",
"Number": "number",
"Function": "function"
}
}],
// best practice
"block-scoped-var": "error",
"curly": "error",
"default-case": "warn",
"dot-location": ["error", "property"],
"dot-notation": "error",
"eqeqeq": ["error", "smart"],
"no-alert": "error",
"no-console": "off",
"no-caller": "error",
"no-case-declarations": "error",
"no-div-regex": "error",
"no-else-return": "error",
"no-empty-pattern": "error",
"no-eq-null": "error",
"no-eval": "error",
"no-extend-native": "error",
"no-extra-bind": "warn",
"no-fallthrough": "error",
"no-floating-decimal": "warn",
"no-implied-eval": "error",
"no-invalid-this": "off",
"no-labels": "error",
"no-lone-blocks": "warn",
"no-loop-func": "error",
"no-multi-spaces": "error",
"no-multi-str": "error",
"no-native-reassign": "error",
"no-new-wrappers": "warn",
"no-new": "warn",
"no-octal-escape": "warn",
"no-octal": "error",
"no-proto": "error",
"no-redeclare": "warn",
"no-script-url": "error",
"no-self-compare": "error",
"no-sequences": "error",
"no-throw-literal": "error",
"flowtype/no-unused-expressions": ["warn", {"allowShortCircuit": true, "allowTernary": true}],
"no-useless-call": "warn",
"no-useless-concat": "warn",
"no-void": "error",
"no-with": "error",
"radix": "error",
"wrap-iife": "error",
"yoda": "warn",
"no-constant-condition": "error",
// strict mode
"strict": ["warn", "never"], // babel все сделает за нас
// variables
"no-catch-shadow": "error",
"no-delete-var": "error",
"no-label-var": "error",
"no-shadow-restricted-names": "error",
"no-shadow": "off",
"no-undef-init": "error",
"no-undef": "error",
"no-use-before-define": ["warn", "nofunc"],
"no-restricted-globals": ["error",
"localStorage", "sessionStorage", // we have our own localStorage module
"event"
],
// CommonJS
"no-mixed-requires": "warn",
"no-path-concat": "warn",
// stylistic
"array-bracket-spacing": "error",
"block-spacing": ["error", "never"],
"brace-style": ["error", "1tbs", {"allowSingleLine": true}],
"comma-spacing": "error",
"comma-style": "error",
"comma-dangle": ["warn", "only-multiline"],
"computed-property-spacing": "error",
"consistent-this": ["error", "that"],
"camelcase": "warn",
"eol-last": "warn",
"id-length": ["error", {"min": 2, "exceptions": ["x", "y", "i", "k", "l", "m", "n", "$", "_"]}],
"indent": ["error", 4, {"SwitchCase": 1}],
"jsx-quotes": "error",
"key-spacing": ["error", {"mode": "minimum"}],
"linebreak-style": "error",
"max-depth": "error",
"new-cap": "error",
"new-parens": "error",
"no-array-constructor": "warn",
"no-bitwise": "warn",
"no-lonely-if": "error",
"no-negated-condition": "warn",
"no-nested-ternary": "error",
"no-new-object": "error",
"no-spaced-func": "error",
"no-trailing-spaces": "warn",
"no-unneeded-ternary": "warn",
"one-var": ["error", "never"],
"operator-assignment": ["warn", "always"],
"operator-linebreak": ["error", "before"],
"padded-blocks": ["warn", "never"],
"quote-props": ["warn", "as-needed"],
"quotes": ["warn", "single"],
"semi": "error",
"semi-spacing": "error",
"keyword-spacing": "warn",
"space-before-blocks": "error",
"space-before-function-paren": ["error", {
"anonymous": "never",
"named": "never",
"asyncArrow": "always"
}],
"space-in-parens": "warn",
"space-infix-ops": "warn",
"space-unary-ops": "error",
"spaced-comment": "warn",
// es6
"arrow-body-style": "off",
"arrow-parens": "error",
"arrow-spacing": "error",
"constructor-super": "error",
"generator-star-spacing": "warn",
"no-class-assign": "error",
"no-const-assign": "error",
"no-dupe-class-members": "error",
"no-this-before-super": "error",
"no-var": "warn",
"object-shorthand": "warn",
"prefer-arrow-callback": "warn",
"prefer-const": "warn",
"prefer-reflect": ["warn", {"exceptions": ["delete"]}],
"prefer-spread": "warn",
"prefer-template": "warn",
"require-yield": "error",
// 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-indent-props": "warn",
"react/jsx-key": "warn",
"react/jsx-max-props-per-line": ["warn", {"maximum": 3}],
"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": "warn",
"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 flowtype for this task
"react/self-closing-comp": "warn",
"react/sort-comp": ["off", {"order": ["lifecycle", "render", "everything-else"]}],
"flowtype/boolean-style": ["error", "bool"],
}
}

173
.eslintrc.js Normal file
View File

@@ -0,0 +1,173 @@
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,
},
},
{
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',
},
},
// @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', '$', '_'] },
],
'require-atomic-updates': 'warn',
'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/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,19 +0,0 @@
[ignore]
.*/node_modules/fbjs/lib/.*
.*/node_modules/react-motion/lib/.*
.*/tests-e2e/.*
[include]
[libs]
./flow-typed
[options]
module.system.node.resolve_dirname=node_modules
module.system.node.resolve_dirname=src
module.file_ext=.js
module.file_ext=.json
module.file_ext=.jsx
module.file_ext=.css
module.file_ext=.scss
module.ignore_non_literal_requires=true

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
node_modules
/dist
/build
/dll
config/*
!config/template.*

6
.prettierignore Normal file
View File

@@ -0,0 +1,6 @@
build
dll
*.jpg
*.png
*.gif
*.svg

6
.prettierrc Normal file
View File

@@ -0,0 +1,6 @@
{
"trailingComma": "all",
"singleQuote": true,
"proseWrap": "always",
"endOfLine": "lf"
}

3
.storybook/addons.js Normal file
View File

@@ -0,0 +1,3 @@
import '@storybook/addon-actions/register';
import '@storybook/addon-links/register';
import '@storybook/addon-viewport/register';

13
.storybook/config.tsx Normal file
View File

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

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { ContextProvider } from 'app/shell';
import { browserHistory } from 'app/services/history';
import storeFactory from 'app/storeFactory';
const store = storeFactory();
export default story => (
<ContextProvider store={store} history={browserHistory}>
{story()}
</ContextProvider>
);

View File

@@ -0,0 +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,
},
resolveLoader: rootConfig.resolveLoader,
});

View File

@@ -15,19 +15,10 @@ env:
- GA_ID=UA-45299905-3
- SENTRY_CDN="https://95461d4ce6734b088c34fc4272d0a9e6@sentry.io/1463318"
- VERSION="${TRAVIS_TAG:-${TRAVIS_BRANCH}-${TRAVIS_COMMIT:0:7}}"
- FORCE_COLOR=1
script:
- yarn lint
- yarn flow
- yarn test
- |
echo "
module.exports = {
version: '$VERSION',
ga: {id: '$GA_ID'},
sentryCdn: '$SENTRY_CDN',
};
" > config/env.js
- yarn ci:check
- yarn build:quiet
before_deploy:
@@ -37,24 +28,24 @@ before_deploy:
- chmod 600 /tmp/deploy_rsa
- ssh-add /tmp/deploy_rsa
# Removing unneeded files
- rm -rf dist/messages
- rm -rf dist/*.css.map
- rm -rf build/messages
- rm -rf build/*.css.map
# Move all source maps to it's own directory
- mkdir -p source-maps
- mv dist/*.js.map source-maps/ 2>/dev/null; true
- cp dist/*.js source-maps/
- mv build/*.js.map source-maps/ 2>/dev/null; true
- cp build/*.js source-maps/
# Creating tar.gz and zip archives
- cd dist
- tar -zcf ../dist.tar.gz --exclude="*.map" *
- zip -rq ../dist.zip * -x "*.map"
- cd build
- tar -zcf ../build.tar.gz --exclude="*.map" *
- zip -rq ../build.zip * -x "*.map"
- cd ..
deploy:
- provider: releases
api_key: "$GITHUB_TOKEN"
file:
- dist.tar.gz
- dist.zip
- build.tar.gz
- build.zip
skip_cleanup: true
draft: true
on:
@@ -62,7 +53,7 @@ deploy:
# - provider: script
# skip_cleanup: true
# script: echo "put -r $TRAVIS_BUILD_DIR/dist/* accounts-frontend/" | sftp deploy@account.ely.by
# script: echo "put -r $TRAVIS_BUILD_DIR/build/* accounts-frontend/" | sftp deploy@account.ely.by
# on:
# branch: master

419
@types/chalk.d.ts vendored Normal file
View File

@@ -0,0 +1,419 @@
/**
* This is a copy-paste from chalk lib, to fix typescript erros
* when isolatedModules is enabled
*/
declare module 'chalk' {
const enum LevelEnum {
/**
All colors disabled.
*/
None = 0,
/**
Basic 16 colors support.
*/
Basic = 1,
/**
ANSI 256 colors support.
*/
Ansi256 = 2,
/**
Truecolor 16 million colors support.
*/
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';
/**
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';
/**
Basic colors.
[More colors here.](https://github.com/chalk/chalk/blob/master/readme.md#256-and-truecolor-color-support)
*/
type Color = ForegroundColor | BackgroundColor;
type Modifiers =
| 'reset'
| 'bold'
| 'dim'
| 'italic'
| 'underline'
| 'inverse'
| 'hidden'
| 'strikethrough'
| 'visible';
namespace chalk {
type Level = LevelEnum;
interface Options {
/**
Specify the color support for Chalk.
By default, color support is automatically detected based on the environment.
*/
level?: Level;
}
interface Instance {
/**
Return a new Chalk instance.
*/
new (options?: Options): Chalk;
}
/**
Detect whether the terminal supports color.
*/
interface ColorSupport {
/**
The color level used by Chalk.
*/
level: Level;
/**
Return whether Chalk supports basic 16 colors.
*/
hasBasic: boolean;
/**
Return whether Chalk supports ANSI 256 colors.
*/
has256: boolean;
/**
Return whether Chalk supports Truecolor 16 million colors.
*/
has16m: boolean;
}
interface ChalkFunction {
/**
Use a template string.
@remarks Template literals are unsupported for nested calls (see [issue #341](https://github.com/chalk/chalk/issues/341))
@example
```
import chalk = require('chalk');
log(chalk`
CPU: {red ${cpu.totalPercent}%}
RAM: {green ${ram.used / ram.total * 100}%}
DISK: {rgb(255,131,0) ${disk.used / disk.total * 100}%}
`);
```
*/
(text: TemplateStringsArray, ...placeholders: unknown[]): string;
(...text: unknown[]): string;
}
interface Chalk extends ChalkFunction {
/**
Return a new Chalk instance.
*/
Instance: Instance;
/**
The color support for Chalk.
By default, color support is automatically detected based on the environment.
*/
level: Level;
/**
Use HEX value to set text color.
@param color - Hexadecimal value representing the desired color.
@example
```
import chalk = require('chalk');
chalk.hex('#DEADED');
```
*/
hex(color: string): Chalk;
/**
Use keyword color value to set text color.
@param color - Keyword value representing the desired color.
@example
```
import chalk = require('chalk');
chalk.keyword('orange');
```
*/
keyword(color: string): Chalk;
/**
Use RGB values to set text color.
*/
rgb(red: number, green: number, blue: number): Chalk;
/**
Use HSL values to set text color.
*/
hsl(hue: number, saturation: number, lightness: number): Chalk;
/**
Use HSV values to set text color.
*/
hsv(hue: number, saturation: number, value: number): Chalk;
/**
Use HWB values to set text color.
*/
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;
/**
Use a [8-bit unsigned number](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) to set text color.
*/
ansi256(index: number): Chalk;
/**
Use HEX value to set background color.
@param color - Hexadecimal value representing the desired color.
@example
```
import chalk = require('chalk');
chalk.bgHex('#DEADED');
```
*/
bgHex(color: string): Chalk;
/**
Use keyword color value to set background color.
@param color - Keyword value representing the desired color.
@example
```
import chalk = require('chalk');
chalk.bgKeyword('orange');
```
*/
bgKeyword(color: string): Chalk;
/**
Use RGB values to set background color.
*/
bgRgb(red: number, green: number, blue: number): Chalk;
/**
Use HSL values to set background color.
*/
bgHsl(hue: number, saturation: number, lightness: number): Chalk;
/**
Use HSV values to set background color.
*/
bgHsv(hue: number, saturation: number, value: number): Chalk;
/**
Use HWB values to set background color.
*/
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;
/**
Use a [8-bit unsigned number](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) to set background color.
*/
bgAnsi256(index: number): Chalk;
/**
Modifier: Resets the current color chain.
*/
readonly reset: Chalk;
/**
Modifier: Make text bold.
*/
readonly bold: Chalk;
/**
Modifier: Emitting only a small amount of light.
*/
readonly dim: Chalk;
/**
Modifier: Make text italic. (Not widely supported)
*/
readonly italic: Chalk;
/**
Modifier: Make text underline. (Not widely supported)
*/
readonly underline: Chalk;
/**
Modifier: Inverse background and foreground colors.
*/
readonly inverse: Chalk;
/**
Modifier: Prints the text, but makes it invisible.
*/
readonly hidden: Chalk;
/**
Modifier: Puts a horizontal line through the center of the text. (Not widely supported)
*/
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 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;
/*
Alias for `blackBright`.
*/
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 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;
/*
Alias for `bgBlackBright`.
*/
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;
}
}
/**
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 };
};
export = chalk;
}

61
@types/webpack-loaders.d.ts vendored Normal file
View File

@@ -0,0 +1,61 @@
declare module '*.html' {
const url: string;
export = url;
}
declare module '*.svg' {
const url: string;
export = url;
}
declare module '*.png' {
const url: string;
export = url;
}
declare module '*.gif' {
const url: string;
export = url;
}
declare module '*.jpg' {
const url: string;
export = url;
}
declare module '*.intl.json' {
import { MessageDescriptor } from 'react-intl';
const descriptor: {
[key: string]: MessageDescriptor;
};
export = descriptor;
}
declare module '*.json' {
const jsonContents: {
[key: string]: any;
};
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;
};
export = classNames;
}
declare module '*.css' {
const classNames: {
[className: string]: string;
};
export = classNames;
}

View File

@@ -3,7 +3,8 @@
[![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
@@ -18,15 +19,16 @@ 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:
@@ -40,7 +42,8 @@ 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>`.
@@ -49,4 +52,5 @@ 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.

42
babel.config.js Normal file
View File

@@ -0,0 +1,42 @@
/* 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-proposal-optional-chaining',
'@babel/plugin-proposal-nullish-coalescing-operator',
[
'@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

@@ -1,3 +0,0 @@
# https://github.com/ai/browserslist#config-file
Last 2 versions

14
config.js Normal file
View File

@@ -0,0 +1,14 @@
/* eslint-env node */
require('dotenv').config();
const { env } = process;
module.exports = {
version: env.VERSION || env.NODE_ENV,
environment: env.ENVIRONMENT || env.NODE_ENV,
apiHost: env.API_HOST || 'https://dev.account.ely.by',
ga: env.GA_ID && { id: env.GA_ID },
sentryCdn: env.SENTRY_CDN,
crowdinApiKey: env.CROWDIN_API_KEY,
};

View File

@@ -1,7 +1,7 @@
module.exports = {
version: 'dev',
apiHost: 'https://dev.account.ely.by',
ga: {id: 'UA-XXXXX-Y'},
sentryCdn: 'https://<key>@sentry.io/<project>',
crowdinApiKey: '',
version: 'dev',
apiHost: 'https://dev.account.ely.by',
ga: { id: 'UA-XXXXX-Y' },
sentryCdn: 'https://<key>@sentry.io/<project>',
crowdinApiKey: '',
};

29
flow-typed/Promise.js vendored
View File

@@ -1,29 +0,0 @@
/**
* This is a copypasted declaration from
* https://github.com/facebook/flow/blob/master/lib/core.js
* with addition of finally method
*/
declare class Promise<+R> {
constructor(callback: (
resolve: (result: Promise<R> | R) => void,
reject: (error: any) => void
) => mixed): void;
then<U>(
onFulfill?: (value: R) => Promise<U> | U,
onReject?: (error: any) => Promise<U> | U
): Promise<U>;
catch<U>(
onReject?: (error: any) => Promise<U> | U
): Promise<R | U>;
static resolve<T>(object: Promise<T> | T): Promise<T>;
static reject<T>(error?: any): Promise<T>;
static all<Elem, T:Iterable<Elem>>(promises: T): Promise<$TupleMap<T, typeof $await>>;
static race<T, Elem: Promise<T> | T>(promises: Array<Elem>): Promise<T>;
finally<T>(
onSettled?: ?(value: any) => Promise<T> | T
): Promise<T>;
}

View File

@@ -1,265 +0,0 @@
// flow-typed signature: e68caa23426dedefced5662fb92b4638
// flow-typed version: d13a175635/react-intl_v2.x.x/flow_>=v0.57.x
/**
* Original implementation of this file by @marudor at https://github.com/marudor/flowInterfaces
* Copied here based on intention to merge with flow-typed expressed here:
* https://github.com/marudor/flowInterfaces/issues/6
*/
// Mostly from https://github.com/yahoo/react-intl/wiki/API#react-intl-api
import type { Element, ChildrenArray } from "react";
type $npm$ReactIntl$LocaleData = {
locale: string,
[key: string]: any
};
type $npm$ReactIntl$MessageDescriptor = {
id: string,
description?: string,
defaultMessage?: string
};
type $npm$ReactIntl$IntlConfig = {
locale: string,
formats: Object,
messages: { [id: string]: string },
defaultLocale?: string,
defaultFormats?: Object
};
type $npm$ReactIntl$IntlProviderConfig = {
locale?: string,
formats?: Object,
messages?: { [id: string]: string },
defaultLocale?: string,
defaultFormats?: Object
};
type $npm$ReactIntl$IntlFormat = {
formatDate: (value: any, options?: Object) => string,
formatTime: (value: any, options?: Object) => string,
formatRelative: (value: any, options?: Object) => string,
formatNumber: (value: any, options?: Object) => string,
formatPlural: (value: any, options?: Object) => string,
formatMessage: (
messageDescriptor: $npm$ReactIntl$MessageDescriptor,
values?: Object
) => string,
formatHTMLMessage: (
messageDescriptor: $npm$ReactIntl$MessageDescriptor,
values?: Object
) => string
};
type $npm$ReactIntl$IntlShape = $npm$ReactIntl$IntlConfig &
$npm$ReactIntl$IntlFormat & { now: () => number };
type $npm$ReactIntl$DateTimeFormatOptions = {
localeMatcher?: "best fit" | "lookup",
formatMatcher?: "basic" | "best fit",
timeZone?: string,
hour12?: boolean,
weekday?: "narrow" | "short" | "long",
era?: "narrow" | "short" | "long",
year?: "numeric" | "2-digit",
month?: "numeric" | "2-digit" | "narrow" | "short" | "long",
day?: "numeric" | "2-digit",
hour?: "numeric" | "2-digit",
minute?: "numeric" | "2-digit",
second?: "numeric" | "2-digit",
timeZoneName?: "short" | "long"
};
type $npm$ReactIntl$RelativeFormatOptions = {
style?: "best fit" | "numeric",
units?: "second" | "minute" | "hour" | "day" | "month" | "year"
};
type $npm$ReactIntl$NumberFormatOptions = {
localeMatcher?: "best fit" | "lookup",
style?: "decimal" | "currency" | "percent",
currency?: string,
currencyDisplay?: "symbol" | "code" | "name",
useGrouping?: boolean,
minimumIntegerDigits?: number,
minimumFractionDigits?: number,
maximumFractionDigits?: number,
minimumSignificantDigits?: number,
maximumSignificantDigits?: number
};
type $npm$ReactIntl$PluralFormatOptions = {
style?: "cardinal" | "ordinal"
};
type $npm$ReactIntl$PluralCategoryString =
| "zero"
| "one"
| "two"
| "few"
| "many"
| "other";
type $npm$ReactIntl$DateParseable = number | string | Date;
declare module "react-intl" {
// PropType checker
declare function intlShape(
props: Object,
propName: string,
componentName: string
): void;
declare function addLocaleData(
data: $npm$ReactIntl$LocaleData | Array<$npm$ReactIntl$LocaleData>
): void;
declare function defineMessages<
T: { [key: string]: $npm$ReactIntl$MessageDescriptor }
>(
messageDescriptors: T
): T;
declare type InjectIntlProvidedProps = {
intl: $npm$ReactIntl$IntlShape
}
declare type ComponentWithDefaultProps<DefaultProps: {}, Props: {}> =
| React$ComponentType<Props>
| React$StatelessFunctionalComponent<Props>
| ChildrenArray<void | null | boolean | string | number | Element<any>>;
declare type InjectIntlOptions = {
intlPropName?: string,
withRef?: boolean
}
declare class IntlInjectedComponent<TOwnProps, TDefaultProps> extends React$Component<TOwnProps> {
static WrappedComponent: Class<React$Component<TOwnProps & InjectIntlProvidedProps>>,
static defaultProps: TDefaultProps,
props: TOwnProps
}
declare type IntlInjectedComponentClass<TOwnProps, TDefaultProps: {} = {}> = Class<
IntlInjectedComponent<TOwnProps, TDefaultProps>
>;
declare function injectIntl<OriginalProps: InjectIntlProvidedProps, DefaultProps: {}>
(
component: ComponentWithDefaultProps<DefaultProps, OriginalProps>,
options?: InjectIntlOptions,
):
IntlInjectedComponentClass<$Diff<OriginalProps, InjectIntlProvidedProps>, DefaultProps>
declare function injectIntl<OriginalProps: InjectIntlProvidedProps>
(
component: React$ComponentType<OriginalProps>,
options?: InjectIntlOptions,
):
IntlInjectedComponentClass<$Diff<OriginalProps, InjectIntlProvidedProps>>;
declare function formatMessage(
messageDescriptor: $npm$ReactIntl$MessageDescriptor,
values?: Object
): string;
declare function formatHTMLMessage(
messageDescriptor: $npm$ReactIntl$MessageDescriptor,
values?: Object
): string;
declare function formatDate(
value: any,
options?: $npm$ReactIntl$DateTimeFormatOptions & { format: string }
): string;
declare function formatTime(
value: any,
options?: $npm$ReactIntl$DateTimeFormatOptions & { format: string }
): string;
declare function formatRelative(
value: any,
options?: $npm$ReactIntl$RelativeFormatOptions & {
format: string,
now: any
}
): string;
declare function formatNumber(
value: any,
options?: $npm$ReactIntl$NumberFormatOptions & { format: string }
): string;
declare function formatPlural(
value: any,
options?: $npm$ReactIntl$PluralFormatOptions
): $npm$ReactIntl$PluralCategoryString;
declare class FormattedMessage extends React$Component<
$npm$ReactIntl$MessageDescriptor & {
values?: Object,
tagName?: string,
children?: (...formattedMessage: Array<React$Node>) => React$Node
}
> {}
declare class FormattedHTMLMessage extends React$Component<
$npm$ReactIntl$DateTimeFormatOptions & {
values?: Object,
tagName?: string,
children?: (...formattedMessage: Array<React$Node>) => React$Node
}
> {}
declare class FormattedDate extends React$Component<
$npm$ReactIntl$DateTimeFormatOptions & {
value: $npm$ReactIntl$DateParseable,
format?: string,
children?: (formattedDate: string) => React$Node
}
> {}
declare class FormattedTime extends React$Component<
$npm$ReactIntl$DateTimeFormatOptions & {
value: $npm$ReactIntl$DateParseable,
format?: string,
children?: (formattedDate: string) => React$Node
}
> {}
declare class FormattedRelative extends React$Component<
$npm$ReactIntl$RelativeFormatOptions & {
value: $npm$ReactIntl$DateParseable,
format?: string,
updateInterval?: number,
initialNow?: $npm$ReactIntl$DateParseable,
children?: (formattedDate: string) => React$Node
}
> {}
declare class FormattedNumber extends React$Component<
$npm$ReactIntl$NumberFormatOptions & {
value: number | string,
format?: string,
children?: (formattedNumber: string) => React$Node
}
> {}
declare class FormattedPlural extends React$Component<
$npm$ReactIntl$PluralFormatOptions & {
value: number | string,
other: React$Node,
zero?: React$Node,
one?: React$Node,
two?: React$Node,
few?: React$Node,
many?: React$Node,
children?: (formattedPlural: React$Node) => React$Node
}
> {}
declare class IntlProvider extends React$Component<
$npm$ReactIntl$IntlProviderConfig & {
children?: React$Node,
initialNow?: $npm$ReactIntl$DateParseable
}
> {}
declare type IntlShape = $npm$ReactIntl$IntlShape;
declare type MessageDescriptor = $npm$ReactIntl$MessageDescriptor;
}

View File

@@ -1,123 +0,0 @@
// flow-typed signature: f7ed1ad96a453a021e6d98c1d144ef43
// flow-typed version: <<STUB>>/react-motion_v0.5.x/flow_v0.53.1
/**
* This is an autogenerated libdef stub for:
*
* 'react-motion'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/
declare module 'react-motion' {
declare module.exports: any;
}
/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module 'react-motion/build/react-motion' {
declare module.exports: any;
}
declare module 'react-motion/lib/mapToZero' {
declare module.exports: any;
}
declare module 'react-motion/lib/mergeDiff' {
declare module.exports: any;
}
declare module 'react-motion/lib/Motion' {
declare module.exports: any;
}
declare module 'react-motion/lib/presets' {
declare module.exports: any;
}
declare module 'react-motion/lib/react-motion' {
declare module.exports: any;
}
declare module 'react-motion/lib/reorderKeys' {
declare module.exports: any;
}
declare module 'react-motion/lib/shouldStopAnimation' {
declare module.exports: any;
}
declare module 'react-motion/lib/spring' {
declare module.exports: any;
}
declare module 'react-motion/lib/StaggeredMotion' {
declare module.exports: any;
}
declare module 'react-motion/lib/stepper' {
declare module.exports: any;
}
declare module 'react-motion/lib/stripStyle' {
declare module.exports: any;
}
declare module 'react-motion/lib/TransitionMotion' {
declare module.exports: any;
}
declare module 'react-motion/lib/Types' {
declare module.exports: any;
}
// Filename aliases
declare module 'react-motion/build/react-motion.js' {
declare module.exports: $Exports<'react-motion/build/react-motion'>;
}
declare module 'react-motion/lib/mapToZero.js' {
declare module.exports: $Exports<'react-motion/lib/mapToZero'>;
}
declare module 'react-motion/lib/mergeDiff.js' {
declare module.exports: $Exports<'react-motion/lib/mergeDiff'>;
}
declare module 'react-motion/lib/Motion.js' {
declare module.exports: $Exports<'react-motion/lib/Motion'>;
}
declare module 'react-motion/lib/presets.js' {
declare module.exports: $Exports<'react-motion/lib/presets'>;
}
declare module 'react-motion/lib/react-motion.js' {
declare module.exports: $Exports<'react-motion/lib/react-motion'>;
}
declare module 'react-motion/lib/reorderKeys.js' {
declare module.exports: $Exports<'react-motion/lib/reorderKeys'>;
}
declare module 'react-motion/lib/shouldStopAnimation.js' {
declare module.exports: $Exports<'react-motion/lib/shouldStopAnimation'>;
}
declare module 'react-motion/lib/spring.js' {
declare module.exports: $Exports<'react-motion/lib/spring'>;
}
declare module 'react-motion/lib/StaggeredMotion.js' {
declare module.exports: $Exports<'react-motion/lib/StaggeredMotion'>;
}
declare module 'react-motion/lib/stepper.js' {
declare module.exports: $Exports<'react-motion/lib/stepper'>;
}
declare module 'react-motion/lib/stripStyle.js' {
declare module.exports: $Exports<'react-motion/lib/stripStyle'>;
}
declare module 'react-motion/lib/TransitionMotion.js' {
declare module.exports: $Exports<'react-motion/lib/TransitionMotion'>;
}
declare module 'react-motion/lib/Types.js' {
declare module.exports: $Exports<'react-motion/lib/Types'>;
}

View File

@@ -1,114 +0,0 @@
// flow-typed signature: c5fac64666f9589a0c1b2de956dc7919
// flow-typed version: 81d6274128/react-redux_v5.x.x/flow_>=v0.53.x
// flow-typed signature: 8db7b853f57c51094bf0ab8b2650fd9c
// flow-typed version: ab8db5f14d/react-redux_v5.x.x/flow_>=v0.30.x
import type { Dispatch, Store } from "redux";
declare module "react-redux" {
/*
S = State
A = Action
OP = OwnProps
SP = StateProps
DP = DispatchProps
*/
declare type MapStateToProps<S, OP: Object, SP: Object> = (
state: S,
ownProps: OP
) => SP | MapStateToProps<S, OP, SP>;
declare type MapDispatchToProps<A, OP: Object, DP: Object> =
| ((dispatch: Dispatch<A>, ownProps: OP) => DP)
| DP;
declare type MergeProps<SP, DP: Object, OP: Object, P: Object> = (
stateProps: SP,
dispatchProps: DP,
ownProps: OP
) => P;
declare type Context = { store: Store<*, *> };
declare class ConnectedComponent<OP, P> extends React$Component<OP> {
static WrappedComponent: Class<React$Component<P>>,
getWrappedInstance(): React$Component<P>,
props: OP,
state: void
}
declare type ConnectedComponentClass<OP, P> = Class<
ConnectedComponent<OP, P>
>;
declare type Connector<OP, P> = (
component: React$ComponentType<P>
) => ConnectedComponentClass<OP, P>;
declare class Provider<S, A> extends React$Component<{
store: Store<S, A>,
children?: any
}> {}
declare function createProvider(
storeKey?: string,
subKey?: string
): Provider<*, *>;
declare type ConnectOptions = {
pure?: boolean,
withRef?: boolean
};
declare type Null = null | void;
declare function connect<A, OP>(
...rest: Array<void> // <= workaround for https://github.com/facebook/flow/issues/2360
): Connector<OP, $Supertype<{ dispatch: Dispatch<A> } & OP>>;
declare function connect<A, OP>(
mapStateToProps: Null,
mapDispatchToProps: Null,
mergeProps: Null,
options: ConnectOptions
): Connector<OP, $Supertype<{ dispatch: Dispatch<A> } & OP>>;
declare function connect<S, A, OP, SP>(
mapStateToProps: MapStateToProps<S, OP, SP>,
mapDispatchToProps: Null,
mergeProps: Null,
options?: ConnectOptions
): Connector<OP, $Supertype<SP & { dispatch: Dispatch<A> } & OP>>;
declare function connect<A, OP, DP>(
mapStateToProps: Null,
mapDispatchToProps: MapDispatchToProps<A, OP, DP>,
mergeProps: Null,
options?: ConnectOptions
): Connector<OP, $Supertype<DP & OP>>;
declare function connect<S, A, OP, SP, DP>(
mapStateToProps: MapStateToProps<S, OP, SP>,
mapDispatchToProps: MapDispatchToProps<A, OP, DP>,
mergeProps: Null,
options?: ConnectOptions
): Connector<OP, $Supertype<SP & DP & OP>>;
declare function connect<S, A, OP, SP, DP, P>(
mapStateToProps: MapStateToProps<S, OP, SP>,
mapDispatchToProps: Null,
mergeProps: MergeProps<SP, DP, OP, P>,
options?: ConnectOptions
): Connector<OP, P>;
declare function connect<S, A, OP, SP, DP, P>(
mapStateToProps: MapStateToProps<S, OP, SP>,
mapDispatchToProps: MapDispatchToProps<A, OP, DP>,
mergeProps: MergeProps<SP, DP, OP, P>,
options?: ConnectOptions
): Connector<OP, P>;
}

View File

@@ -1,139 +0,0 @@
// flow-typed signature: b45080e7e6a55f1c9092f07c205cd527
// flow-typed version: 3e35e41eb5/react-router_v4.x.x/flow_v0.53.x
// flow-typed signature: b701192ca557cf27adf1b295517299fd
// flow-typed version: b43dff3e0e/react-router_v4.x.x/flow_>=v0.53.x
import * as React from "react";
declare module "react-router" {
// NOTE: many of these are re-exported by react-router-dom and
// react-router-native, so when making changes, please be sure to update those
// as well.
declare export type Location = {
pathname: string,
search: string,
hash: string,
state?: any,
key?: string
};
declare export type LocationShape = {
pathname?: string,
search?: string,
hash?: string,
state?: any
};
declare export type HistoryAction = "PUSH" | "REPLACE" | "POP";
declare export type RouterHistory = {
length: number,
location: Location,
action: HistoryAction,
listen(
callback: (location: Location, action: HistoryAction) => void
): () => void,
push(path: string | LocationShape, state?: any): void,
replace(path: string | LocationShape, state?: any): void,
go(n: number): void,
goBack(): void,
goForward(): void,
canGo?: (n: number) => boolean,
block(
callback: (location: Location, action: HistoryAction) => boolean
): void,
// createMemoryHistory
index?: number,
entries?: Array<Location>
};
declare export type Match = {
params: { [key: string]: ?string },
isExact: boolean,
path: string,
url: string
};
declare export type ContextRouter = {
history: RouterHistory,
location: Location,
match: Match
};
declare export type GetUserConfirmation = (
message: string,
callback: (confirmed: boolean) => void
) => void;
declare type StaticRouterContext = {
url?: string
};
declare type StaticRouterProps = {
basename?: string,
location?: string | Location,
context: StaticRouterContext,
children?: React$Element<*>
};
declare export class StaticRouter extends React$Component<
StaticRouterProps
> {}
declare type MemoryRouterProps = {
initialEntries?: Array<LocationShape | string>,
initialIndex?: number,
getUserConfirmation?: GetUserConfirmation,
keyLength?: number,
children?: React$Element<*>
};
declare export class MemoryRouter extends React$Component<
MemoryRouterProps
> {}
declare type RouterProps = {
history: RouterHistory,
children?: React$Element<*>
};
declare export class Router extends React$Component<RouterProps> {}
declare type PromptProps = {
message: string | ((location: Location) => string | true),
when?: boolean
};
declare export class Prompt extends React$Component<PromptProps> {}
declare type RedirectProps = {
to: string | LocationShape,
push?: boolean
};
declare export class Redirect extends React$Component<RedirectProps> {}
declare type RouteProps = {
component?: React$ComponentType<*>,
render?: (router: ContextRouter) => React$Element<*>,
children?: (router: ContextRouter) => React$Element<*>,
path?: string,
exact?: boolean,
strict?: boolean
};
declare export class Route extends React$Component<RouteProps> {}
declare type SwithcProps = {
children?: Array<React$Element<*>>
};
declare export class Switch extends React$Component<SwithcProps> {}
declare export function withRouter<P>(
Component: React$ComponentType<ContextRouter & P>
): React$ComponentType<P>;
declare type MatchPathOptions = {
exact?: boolean,
strict?: boolean
};
declare export function matchPath(
pathname: string,
path: string,
options?: MatchPathOptions
): null | Match;
}

View File

@@ -1,109 +0,0 @@
// flow-typed signature: 86993bd000012d3e1ef10d757d16952d
// flow-typed version: a165222d28/redux_v3.x.x/flow_>=v0.33.x
declare module 'redux' {
/*
S = State
A = Action
D = Dispatch
*/
declare type DispatchAPI<A> = (action: A) => A;
declare type Dispatch<A: { type: $Subtype<string> }> = DispatchAPI<A>;
declare type MiddlewareAPI<S, A, D = Dispatch<A>> = {
dispatch: D;
getState(): S;
};
declare type Store<S, A, D = Dispatch<A>> = {
// rewrite MiddlewareAPI members in order to get nicer error messages (intersections produce long messages)
dispatch: D;
getState(): S;
subscribe(listener: () => void): () => void;
replaceReducer(nextReducer: Reducer<S, A>): void
};
declare type Reducer<S, A> = (state: S, action: A) => S;
declare type CombinedReducer<S, A> = (state: $Shape<S> & {} | void, action: A) => S;
declare type Middleware<S, A, D = Dispatch<A>> =
(api: MiddlewareAPI<S, A, D>) =>
(next: D) => D;
declare type StoreCreator<S, A, D = Dispatch<A>> = {
(reducer: Reducer<S, A>, enhancer?: StoreEnhancer<S, A, D>): Store<S, A, D>;
(reducer: Reducer<S, A>, preloadedState: S, enhancer?: StoreEnhancer<S, A, D>): Store<S, A, D>;
};
declare type StoreEnhancer<S, A, D = Dispatch<A>> = (next: StoreCreator<S, A, D>) => StoreCreator<S, A, D>;
declare function createStore<S, A, D>(reducer: Reducer<S, A>, enhancer?: StoreEnhancer<S, A, D>): Store<S, A, D>;
declare function createStore<S, A, D>(reducer: Reducer<S, A>, preloadedState: S, enhancer?: StoreEnhancer<S, A, D>): Store<S, A, D>;
declare function applyMiddleware<S, A, D>(...middlewares: Array<Middleware<S, A, D>>): StoreEnhancer<S, A, D>;
declare type ActionCreator<A, B> = (...args: Array<B>) => A;
declare type ActionCreators<K, A> = { [key: K]: ActionCreator<A, any> };
declare function bindActionCreators<A, C: ActionCreator<A, any>, D: DispatchAPI<A>>(actionCreator: C, dispatch: D): C;
declare function bindActionCreators<A, K, C: ActionCreators<K, A>, D: DispatchAPI<A>>(actionCreators: C, dispatch: D): C;
declare function combineReducers<O: Object, A>(reducers: O): CombinedReducer<$ObjMap<O, <S>(r: Reducer<S, any>) => S>, A>;
declare function compose<A, B>(ab: (a: A) => B): (a: A) => B
declare function compose<A, B, C>(
bc: (b: B) => C,
ab: (a: A) => B
): (a: A) => C
declare function compose<A, B, C, D>(
cd: (c: C) => D,
bc: (b: B) => C,
ab: (a: A) => B
): (a: A) => D
declare function compose<A, B, C, D, E>(
de: (d: D) => E,
cd: (c: C) => D,
bc: (b: B) => C,
ab: (a: A) => B
): (a: A) => E
declare function compose<A, B, C, D, E, F>(
ef: (e: E) => F,
de: (d: D) => E,
cd: (c: C) => D,
bc: (b: B) => C,
ab: (a: A) => B
): (a: A) => F
declare function compose<A, B, C, D, E, F, G>(
fg: (f: F) => G,
ef: (e: E) => F,
de: (d: D) => E,
cd: (c: C) => D,
bc: (b: B) => C,
ab: (a: A) => B
): (a: A) => G
declare function compose<A, B, C, D, E, F, G, H>(
gh: (g: G) => H,
fg: (f: F) => G,
ef: (e: E) => F,
de: (d: D) => E,
cd: (c: C) => D,
bc: (b: B) => C,
ab: (a: A) => B
): (a: A) => H
declare function compose<A, B, C, D, E, F, G, H, I>(
hi: (h: H) => I,
gh: (g: G) => H,
fg: (f: F) => G,
ef: (e: E) => F,
de: (d: D) => E,
cd: (c: C) => D,
bc: (b: B) => C,
ab: (a: A) => B
): (a: A) => I
}

View File

@@ -0,0 +1,19 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-var-requires */
/* eslint-env node */
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}/../../..`));
},
};

View File

@@ -0,0 +1 @@
module.exports = 'test-file-stub';

23
jest/setupAfterEnv.js Normal file
View File

@@ -0,0 +1,23 @@
import 'app/polyfills';
import { configure } from 'enzyme';
import Adapter from 'enzyme-adapter-react-16';
configure({ adapter: new Adapter() });
if (!window.localStorage) {
window.localStorage = {
getItem(key) {
return this[key] || null;
},
setItem(key, value) {
this[key] = value;
},
removeItem(key) {
delete this[key];
},
};
window.sessionStorage = {
...window.localStorage,
};
}

View File

@@ -1,83 +0,0 @@
/* eslint-env node */
// https://docs.gitlab.com/ce/ci/variables/README.html
// noinspection Eslint
const isCi = typeof process.env.CI !== 'undefined';
module.exports = function(config) {
const params = {
// base path that will be used to resolve all patterns (eg. files, exclude)
basePath: '',
// frameworks to use
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
frameworks: ['mocha', 'sinon'],
// list of files / patterns to load in the browser
files: [
'dll/vendor.dll.js',
'src/test.js'
],
// list of files to exclude
exclude: [
],
// preprocess matching files before serving them to the browser
// available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor
preprocessors: {
'src/test.js': ['webpack', 'sourcemap']
},
webpack: require('./webpack.config.js'),
webpackServer: {
noInfo: true // please don't spam the console when running in karma!
},
// test results reporter to use
// possible values: 'dots', 'progress'
// available reporters: https://npmjs.org/browse/keyword/karma-reporter
reporters: ['nyan'],
nyanReporter: {
// suppress the red background on errors in the error
// report at the end of the test run
suppressErrorHighlighting: true
},
// web server port
port: 9876,
// enable / disable colors in the output (reporters and logs)
colors: true,
// level of logging
// possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG
logLevel: config.LOG_INFO,
// enable / disable watching file and executing tests whenever any file changes
autoWatch: true,
// start these browsers
// available browser launchers: https://npmjs.org/browse/keyword/karma-launcher
browsers: ['jsdom'],
// Continuous Integration mode
// if true, Karma captures browsers, runs the tests and exits
singleRun: false
};
if (isCi) {
Object.assign(params, {
reporters: ['dots'],
autoWatch: false,
singleRun: true,
client: {
captureConsole: false
}
});
}
config.set(params);
};

View File

@@ -1,8 +1,8 @@
{
"name": "@elyby/accounts-frontend",
"description": "",
"main": "src/index.js",
"author": "SleepWalker <mybox@udf.su>",
"private": true,
"maintainers": [
{
"name": "ErickSkrauch",
@@ -16,113 +16,162 @@
"license": "Apache-2.0",
"repository": "https://github.com/elyby/accounts-frontend",
"engines": {
"node": ">=8.0.0"
"node": ">=10.0.0"
},
"workspaces": [
"packages/*",
"tests-e2e"
],
"scripts": {
"start": "yarn run clean && yarn run build:dll && NODE_PATH=./src webpack-dev-server --progress --colors",
"clean": "rm -rf dist/",
"test": "yarn run build:dll && NODE_PATH=./src karma start ./karma.conf.js",
"lint": "eslint ./src",
"flow": "flow",
"i18n:collect": "babel-node ./scripts/i18n-collect.js",
"i18n:push": "babel-node --presets flow --plugins transform-es2015-modules-commonjs ./scripts/i18n-crowdin.js push",
"i18n:pull": "babel-node --presets flow --plugins transform-es2015-modules-commonjs ./scripts/i18n-crowdin.js pull",
"build": "yarn run clean && yarn run build:webpack --progress",
"build:install": "yarn install && check-node-version",
"build:webpack": "NODE_PATH=./src webpack --colors -p --bail",
"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 --quiet .",
"lint:check": "eslint --ext js,ts,tsx --quiet .",
"prettier": "prettier --write \"{packages/**/*,tests-e2e/**/*,jest/**/*,config/**/*,*}.{js,ts,tsx,json,md,scss,css}\"",
"prettier:check": "prettier --check \"{packages/**/*,tests-e2e/**/*,jest/**/*,config/**/*,*}.{js,ts,tsx,json,md,scss,css}\"",
"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 ./packages/scripts/i18n-collect.js",
"i18n:push": "babel-node ./packages/scripts/i18n-crowdin.js push",
"i18n:pull": "babel-node ./packages/scripts/i18n-crowdin.js 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": "node ./scripts/build-dll.js"
"build:dll": "node ./packages/scripts/build-dll.js",
"build:serve": "http-server --proxy https://dev.account.ely.by ./build",
"sb": "APP_ENV=storybook start-storybook -p 9009 --ci",
"build-storybook": "build-storybook"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged",
"pre-push": "yarn ci:check"
}
},
"lint-staged": {
"*.{json,scss,css,md}": [
"prettier --write",
"git add"
],
"*.{js,ts,tsx}": [
"eslint --fix --quiet",
"git add"
]
},
"jest": {
"roots": [
"<rootDir>/packages/app"
],
"setupFilesAfterEnv": [
"<rootDir>/jest/setupAfterEnv.js"
],
"resetMocks": true,
"resetModules": 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"
}
},
"resolutions": {
"@types/node": "^12.0.0"
},
"dependencies": {
"babel-polyfill": "^6.3.14",
"classnames": "^2.2.6",
"copy-to-clipboard": "^3.0.8",
"debounce": "^1.0.0",
"flag-icon-css": "^2.8.0",
"intl": "^1.2.2",
"intl-format-cache": "^2.0.4",
"intl-messageformat": "^2.1.0",
"promise.prototype.finally": "3.1.0",
"prop-types": "^15.6.2",
"raf": "^3.4.1",
"raven-js": "^3.27.0",
"react": "^16.7.0",
"react-dom": "^16.7.0",
"react-helmet": "^5.0.0",
"react-intl": "^2.7.2",
"react-motion": "^0.5.0",
"react-redux": "^5.0.6",
"react-router-dom": "^4.3.1",
"react-textarea-autosize": "^6.0.0",
"react-transition-group": "^1.1.3",
"redux": "^3.0.4",
"redux-localstorage": "^0.4.1",
"redux-thunk": "^2.0.0",
"url-search-params-polyfill": "^5.0.0",
"webfontloader": "^1.6.26",
"whatwg-fetch": "^3.0.0"
"react": "^16.12.0",
"react-dom": "^16.12.0"
},
"devDependencies": {
"babel-cli": "^6.18.0",
"babel-core": "^6.0.0",
"babel-eslint": "^9.0.0",
"babel-loader": "^6.0.0",
"babel-plugin-react-intl": "^2.0.0",
"babel-plugin-transform-function-bind": "^6.0.0",
"babel-plugin-transform-runtime": "^6.3.13",
"babel-preset-airbnb": "^2.0.0",
"babel-preset-es2015": "^6.3.13",
"babel-preset-es2017": "^6.16.0",
"babel-preset-flow": "^6.23.0",
"babel-preset-react": "^6.3.13",
"babel-preset-react-hmre": "^1.0.1",
"babel-preset-stage-0": "^6.3.13",
"babel-runtime": "^6.0.0",
"bundle-loader": "^0.5.4",
"check-node-version": "^2.1.0",
"csp-webpack-plugin": "^1.0.2",
"css-loader": "^0.28.0",
"enzyme": "^3.8.0",
"enzyme-adapter-react-16": "^1.7.1",
"eslint": "^4.0.0",
"eslint-plugin-flowtype": "^2.46.3",
"eslint-plugin-react": "^7.3.0",
"exports-loader": "^0.6.3",
"extract-text-webpack-plugin": "^1.0.0",
"file-loader": "^0.11.0",
"flow-bin": "~0.80.0",
"fontgen-loader": "^0.2.1",
"html-loader": "^0.4.3",
"html-webpack-plugin": "^2.0.0",
"imports-loader": "^0.7.0",
"jsdom": "^9.8.3",
"@babel/cli": "^7.7.7",
"@babel/core": "^7.7.7",
"@babel/node": "^7.7.7",
"@babel/plugin-proposal-class-properties": "^7.7.4",
"@babel/plugin-proposal-decorators": "^7.7.4",
"@babel/plugin-proposal-do-expressions": "^7.7.4",
"@babel/plugin-proposal-export-default-from": "^7.7.4",
"@babel/plugin-proposal-export-namespace-from": "^7.7.4",
"@babel/plugin-proposal-function-bind": "^7.7.4",
"@babel/plugin-proposal-function-sent": "^7.7.4",
"@babel/plugin-proposal-json-strings": "^7.7.4",
"@babel/plugin-proposal-logical-assignment-operators": "^7.7.4",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.7.4",
"@babel/plugin-proposal-numeric-separator": "^7.7.4",
"@babel/plugin-proposal-optional-chaining": "^7.7.5",
"@babel/plugin-proposal-pipeline-operator": "^7.7.7",
"@babel/plugin-proposal-throw-expressions": "^7.7.4",
"@babel/plugin-syntax-dynamic-import": "^7.7.4",
"@babel/plugin-syntax-import-meta": "^7.7.4",
"@babel/plugin-transform-runtime": "^7.7.6",
"@babel/preset-env": "^7.7.7",
"@babel/preset-react": "^7.7.4",
"@babel/preset-typescript": "^7.7.7",
"@babel/runtime-corejs3": "^7.7.7",
"@storybook/addon-actions": "^5.2.8",
"@storybook/addon-links": "^5.2.8",
"@storybook/addon-viewport": "^5.2.8",
"@storybook/addons": "^5.2.8",
"@storybook/react": "^5.2.8",
"@types/jest": "^24.0.25",
"@typescript-eslint/eslint-plugin": "^2.13.0",
"@typescript-eslint/parser": "^2.13.0",
"babel-loader": "^8.0.0",
"babel-plugin-react-intl": "^5.1.13",
"core-js": "3.6.1",
"csp-webpack-plugin": "^2.0.2",
"css-loader": "^3.4.0",
"cssnano": "^4.1.10",
"dotenv": "^8.2.0",
"eager-imports-webpack-plugin": "^1.0.0",
"eslint": "^6.8.0",
"eslint-config-prettier": "^6.9.0",
"eslint-plugin-jsdoc": "^18.6.2",
"eslint-plugin-prettier": "^3.1.2",
"eslint-plugin-react": "^7.17.0",
"exports-loader": "^0.7.0",
"file-loader": "^5.0.2",
"html-loader": "^0.5.5",
"html-webpack-plugin": "^3.2.0",
"husky": "^3.1.0",
"identity-obj-proxy": "^3.0.0",
"imports-loader": "^0.8.0",
"jest": "^24.9.0",
"jest-watch-typeahead": "^0.4.2",
"json-loader": "^0.5.4",
"karma": "^1.1.0",
"karma-jsdom-launcher": "^6.0.0",
"karma-mocha": "^1.0.0",
"karma-nyan-reporter": "^0.2.3",
"karma-sinon": "^1.0.4",
"karma-sourcemap-loader": "*",
"karma-webpack": "^2.0.0",
"lint-staged": "^9.5.0",
"loader-utils": "^1.0.0",
"mocha": "^3.0.2",
"node-sass": "^4.7.2",
"postcss-import": "^9.0.0",
"postcss-loader": "^1.2.0",
"postcss-scss": "^0.4.0",
"postcss-url": "SleepWalker/postcss-url#switch-to-async-api",
"raw-loader": "^0.5.1",
"react-test-renderer": "^16.7.0",
"sass-loader": "^4.0.0",
"scripts": "file:./scripts",
"sinon": "^3.2.1",
"sitemap-webpack-plugin": "^0.5.1",
"style-loader": "^0.18.0",
"unexpected": "^10.33.2",
"mini-css-extract-plugin": "^0.9.0",
"node-sass": "^4.13.0",
"postcss-import": "^12.0.1",
"postcss-loader": "^3.0.0",
"postcss-scss": "^2.0.0",
"prettier": "^1.19.1",
"raw-loader": "^4.0.0",
"react-test-renderer": "^16.12.0",
"sass-loader": "^8.0.0",
"sinon": "^8.0.1",
"sitemap-webpack-plugin": "^0.6.0",
"speed-measure-webpack-plugin": "^1.3.1",
"style-loader": "~1.0.0",
"typescript": "^3.7.4",
"unexpected": "^11.12.0",
"unexpected-sinon": "^10.5.1",
"url-loader": "^0.5.7",
"webpack": "^1.12.9",
"webpack-dev-server": "^1.14.0",
"webpack-utils": "file:./webpack-utils"
"url-loader": "^3.0.0",
"webpack": "^4.41.5",
"webpack-bundle-analyzer": "^3.6.0",
"webpack-cli": "^3.3.10",
"webpack-dev-server": "^3.10.1",
"webpackbar": "^4.0.0"
}
}

View File

@@ -0,0 +1,72 @@
import React from 'react';
import { omit, debounce } from 'app/functions';
/**
* MeasureHeight is a component that allows you to measure the height of elements wrapped.
*
* Each time the height changed, the `onMeasure` prop will be called.
* On each component update the `shouldMeasure` prop is being called and depending of
* the value returned will be decided whether to call `onMeasure`.
* By default `shouldMeasure` will compare the old and new values of the `state` prop.
* Both `shouldMeasure` and `state` can be used to reduce the amount of measures, which
* will reduce the count of forced reflows in browser.
*
* Usage:
* <MeasureHeight
* state={theValueToInvalidateCurrentMeasure}
* onMeasure={this.onUpdateContextHeight}
* >
* <div>some content here</div>
* <div>which may be multiple children</div>
* </MeasureHeight>
*/
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>
> {
static defaultProps = {
shouldMeasure: (prevState: ChildState, newState: ChildState) =>
prevState !== newState,
onMeasure: () => {},
};
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();
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.enqueueMeasurement);
}
render() {
const props = omit(this.props, ['shouldMeasure', 'onMeasure', 'state']);
return <div {...props} ref={(el: HTMLDivElement) => (this.el = el)} />;
}
measure = () => {
requestAnimationFrame(() => {
this.el && this.props.onMeasure(this.el.offsetHeight);
});
};
enqueueMeasurement = debounce(this.measure);
}

View File

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

View File

@@ -0,0 +1,201 @@
import React from 'react';
import { connect } from 'react-redux';
import clsx from 'clsx';
import { Link } from 'react-router-dom';
import { FormattedMessage as Message } from 'react-intl';
import loader from 'app/services/loader';
import { SKIN_DARK, COLOR_WHITE, Skin } from 'app/components/ui';
import { Button } from 'app/components/ui/form';
import { authenticate, revoke } from 'app/components/accounts/actions';
import { getActiveAccount, Account } from 'app/components/accounts/reducer';
import { RootState } from 'app/reducers';
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;
}
export class AccountSwitcher extends React.Component<Props> {
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 });
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>
);
}
onSwitch = (account: Account) => (event: React.MouseEvent) => {
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) => {
event.preventDefault();
event.stopPropagation();
this.props.removeAccount(account).then(() => this.props.onAfterAction());
};
}
export default connect(
({ accounts }: RootState) => ({
accounts,
}),
{
switchAccount: authenticate,
removeAccount: revoke,
},
)(AccountSwitcher);

View File

@@ -0,0 +1,225 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
// TODO: эту константу можно заимпортить из panel.scss, но это приводит к странным ошибкам
//@import '~app/components/ui/panel.scss';
$bodyLeftRightPadding: 20px;
$lightBorderColor: #eee;
.accountSwitcher {
text-align: left;
}
.accountInfo {
}
.accountUsername,
.accountEmail {
overflow: hidden;
text-overflow: ellipsis;
}
.lightAccountSwitcher {
background: #fff;
color: #444;
min-width: 205px;
$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;
}
&: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;
}
}
.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;
$border: 1px solid lighter($black);
.item {
padding: 15px 20px;
border-top: 1px solid lighter($black);
transition: 0.25s;
cursor: pointer;
&:hover {
background-color: lighter($black);
}
&:active {
background-color: darker($black);
}
&:last-of-type {
border-bottom: $border;
}
}
.accountIcon {
font-size: 35px;
}
.accountInfo {
margin-left: 30px;
margin-right: 26px;
}
.accountUsername {
font-family: $font-family-title;
color: #fff;
}
.accountEmail {
color: #666;
font-size: 12px;
}
}
.accountIcon {
composes: minecraft-character from '~app/components/ui/icons.scss';
float: left;
&1 {
color: $green;
}
&2 {
color: $blue;
}
&3 {
color: $violet;
}
&4 {
color: $orange;
}
&5 {
color: $dark_blue;
}
&6 {
color: $light_violet;
}
&7 {
color: $red;
}
}
.addIcon {
composes: plus from '~app/components/ui/icons.scss';
color: $green;
position: relative;
bottom: 1px;
margin-right: 3px;
}
.nextIcon {
composes: arrowRight from '~app/components/ui/icons.scss';
position: relative;
float: right;
font-size: 24px;
color: #4e4e4e;
line-height: 35px;
left: 0;
transition: color 0.25s, left 0.5s;
.item:hover & {
color: #aaa;
left: 5px;
}
}
.logoutIcon {
composes: exit from '~app/components/ui/icons.scss';
color: #cdcdcd;
float: right;
line-height: 27px;
transition: 0.25s;
&:hover {
color: #777;
}
}

View File

@@ -0,0 +1,497 @@
import expect from 'app/test/unexpected';
import sinon from 'sinon';
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,
ADD,
activate,
ACTIVATE,
remove,
reset,
} from 'app/components/accounts/actions/pure-actions';
import { SET_LOCALE } from 'app/components/i18n/actions';
import { updateUser, setUser } from 'app/components/user/actions';
import { setLogin, setAccountSwitcher } from 'app/components/auth/actions';
import { Dispatch, RootState } from 'app/reducers';
import { Account } from './reducer';
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',
};
const user = {
id: 1,
username: 'username',
email: 'email@test.com',
lang: 'be',
};
describe('components/accounts/actions', () => {
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');
(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 ${ADD} action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [add(account)]),
));
it(`dispatches ${ACTIVATE} action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]),
));
it(`dispatches ${SET_LOCALE} action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [
{ type: SET_LOCALE, 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: [],
},
},
});
});
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('#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,
});
});
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

@@ -0,0 +1,370 @@
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 { 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';
export { updateToken, activate, remove };
/**
* @param {Account|object} account
* @param {string} account.token
* @param {string} account.refreshToken
*
* @returns {Function}
*/
export function authenticate(
account:
| Account
| {
token: string;
refreshToken: string | null;
},
): ThunkAction<Promise<Account>> {
const { token, refreshToken } = account;
const email = 'email' in account ? account.email : null;
return async (dispatch, getState) => {
let accountId: number;
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,
);
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,
}),
);
// 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 (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));
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;
}
};
}
/**
* Re-fetch user data for currently active account
*/
export function refreshUserData(): ThunkAction<Promise<void>> {
return async (dispatch, getState) => {
const activeAccount = getActiveAccount(getState());
if (!activeAccount) {
throw new Error('Can not fetch user data. No user.id available');
}
await dispatch(authenticate(activeAccount));
};
}
function findAccountIdFromToken(token: string): number {
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);
}
// 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');
}
/**
* Checks the current user's token exp time. Supposed to be used before performing
* any api request
*
* @see components/user/middlewares/refreshTokenMiddleware
*
* @returns {Function}
*/
export function ensureToken(): ThunkAction<Promise<void>> {
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);
if (exp - SAFETY_FACTOR < Date.now() / 1000) {
return dispatch(requestNewToken());
}
} catch (err) {
logger.warn('Refresh token error: bad token', {
token,
});
dispatch(relogin());
return Promise.reject(new Error('Invalid token'));
}
return Promise.resolve();
};
}
/**
* Checks whether request `error` is an auth error and tries recover from it by
* requesting a new auth token
*
* @see components/user/middlewares/refreshTokenMiddleware
*
* @param {object} error
*
* @returns {Function}
*/
export function recoverFromTokenError(
error: {
status: number;
message: string;
} | void,
): ThunkAction<Promise<void>> {
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());
}
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);
};
}
/**
* Requests new token and updates state. In case, when token can not be updated,
* it will redirect user to login page
*
* @returns {Function}
*/
export function requestNewToken(): ThunkAction<Promise<void>> {
return (dispatch, getState) => {
const { refreshToken } = getActiveAccount(getState()) || {};
if (!refreshToken) {
dispatch(relogin());
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 Promise.reject(resp);
});
};
}
/**
* Remove one account from current user's account list
*
* @param {Account} account
*
* @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;
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 dispatch(logoutAll());
};
}
export function relogin(email?: string): ThunkAction<Promise<void>> {
return async (dispatch, getState) => {
const activeAccount = getActiveAccount(getState());
if (!email && activeAccount) {
email = activeAccount.email;
}
dispatch(navigateToLogin(email || null));
};
}
export function logoutAll(): ThunkAction<Promise<void>> {
return (dispatch, getState) => {
dispatch(setGuest());
const {
accounts: { available },
} = getState();
available.forEach(account =>
logout(account.token).catch(() => {
// we don't care
}),
);
dispatch(reset());
dispatch(relogin());
return Promise.resolve();
};
}
/**
* Logouts accounts, that was marked as "do not remember me"
*
* We detecting foreign accounts by the absence of refreshToken. The account
* won't be removed, until key `stranger${account.id}` is present in sessionStorage
*
* @returns {Function}
*/
export function logoutStrangers(): ThunkAction<Promise<void>> {
return async (dispatch, getState) => {
const {
accounts: { available },
} = getState();
const activeAccount = getActiveAccount(getState());
const isStranger = ({ refreshToken, id }: Account) =>
!refreshToken && !sessionStorage.getItem(`stranger${id}`);
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 (activeAccount && isStranger(activeAccount)) {
await dispatch(authenticate(accountToReplace));
return;
}
} else {
await dispatch(logoutAll());
return;
}
}
return Promise.resolve();
};
}

View File

@@ -0,0 +1,78 @@
import {
Account,
AddAction,
RemoveAction,
ActivateAction,
UpdateTokenAction,
ResetAction,
} from '../reducer';
export const ADD = 'accounts:add';
/**
* @private
*
* @param {Account} account
*
* @returns {object} - action definition
*/
export function add(account: Account): AddAction {
return {
type: ADD,
payload: account,
};
}
export const REMOVE = 'accounts:remove';
/**
* @private
*
* @param {Account} account
*
* @returns {object} - action definition
*/
export function remove(account: Account): RemoveAction {
return {
type: REMOVE,
payload: account,
};
}
export const ACTIVATE = 'accounts:activate';
/**
* @private
*
* @param {Account} account
*
* @returns {object} - action definition
*/
export function activate(account: Account): ActivateAction {
return {
type: ACTIVATE,
payload: account,
};
}
export const RESET = 'accounts:reset';
/**
* @private
*
* @returns {object} - action definition
*/
export function reset(): ResetAction {
return {
type: RESET,
};
}
export const UPDATE_TOKEN = 'accounts:updateToken';
/**
* @param {string} token
*
* @returns {object} - action definition
*/
export function updateToken(token: string): UpdateTokenAction {
return {
type: UPDATE_TOKEN,
payload: token,
};
}

View File

@@ -0,0 +1,5 @@
import { State, Account as AccountType } from './reducer';
export { default as AccountSwitcher } from './AccountSwitcher';
export type AccountsState = State;
export type Account = AccountType;

View File

@@ -0,0 +1,162 @@
import expect from 'app/test/unexpected';
import { updateToken } from './actions';
import {
add,
remove,
activate,
reset,
ADD,
REMOVE,
ACTIVATE,
UPDATE_TOKEN,
RESET,
} from './actions/pure-actions';
import accounts, { Account } from './reducer';
const account: Account = {
id: 1,
username: 'username',
email: 'email@test.com',
token: 'foo',
} as Account;
describe('Accounts reducer', () => {
let initial;
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(ACTIVATE, () => {
it('sets active account', () => {
expect(accounts(initial, activate(account)), 'to satisfy', {
active: account.id,
});
});
});
describe(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(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',
);
});
});
describe(RESET, () => {
it('should reset accounts state', () =>
expect(
accounts({ ...initial, available: [account] }, reset()),
'to equal',
initial,
));
});
describe(UPDATE_TOKEN, () => {
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

@@ -0,0 +1,136 @@
export type Account = {
id: number;
username: string;
email: string;
token: string;
refreshToken: string | null;
};
export type State = {
active: number | null;
available: Account[];
};
export type AddAction = { type: 'accounts:add'; payload: Account };
export type RemoveAction = { type: 'accounts:remove'; payload: Account };
export type ActivateAction = { type: 'accounts:activate'; payload: Account };
export type UpdateTokenAction = {
type: 'accounts:updateToken';
payload: string;
};
export type ResetAction = { type: 'accounts:reset' };
type Action =
| AddAction
| RemoveAction
| ActivateAction
| UpdateTokenAction
| ResetAction;
export function getActiveAccount(state: { accounts: State }): Account | null {
const accountId = state.accounts.active;
return (
state.accounts.available.find(account => account.id === accountId) || null
);
}
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 {
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;
}

View File

@@ -0,0 +1,16 @@
import React from 'react';
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>
);
}

View File

@@ -0,0 +1,68 @@
import React from 'react';
import AuthError from 'app/components/auth/authError/AuthError';
import { FormModel } from 'app/components/ui/form';
import { RouteComponentProps } from 'react-router-dom';
import Context, { AuthContext } from './Context';
/**
* Helps with form fields binding, form serialization and errors rendering
*/
class BaseAuthBody extends React.Component<
// TODO: this may be converted to generic type RouteComponentProps<T>
RouteComponentProps<{ [key: string]: any }>
> {
static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>;
prevErrors: AuthContext['auth']['error'];
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();
}
this.prevErrors = this.context.auth.error;
}
renderErrors() {
const error = this.form.getFirstError();
return error && <AuthError error={error} onClose={this.onClearErrors} />;
}
onFormSubmit() {
this.context.resolve(this.serialize());
}
onClearErrors = () => this.context.clearErrors();
form = new FormModel({
renderErrors: false,
});
bindField(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

@@ -0,0 +1,34 @@
import React from 'react';
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;
}
const Context = React.createContext<AuthContext>({
auth: {
error: null,
login: '',
scopes: [],
} as any,
user: {
id: null,
isGuest: true,
} as any,
async requestRedraw() {},
clearErrors() {},
resolve() {},
reject() {},
});
Context.displayName = 'AuthContext';
export const { Provider, Consumer } = Context;
export default Context;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
import { State } from './reducer';
export type AuthState = State;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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