mirror of
https://github.com/elyby/accounts-frontend.git
synced 2025-05-31 14:11:58 +05:30
17
.babelrc
17
.babelrc
@@ -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
2
.browserslistrc
Normal file
@@ -0,0 +1,2 @@
|
||||
> 0.25%
|
||||
not dead
|
@@ -1,2 +1,2 @@
|
||||
dist
|
||||
build
|
||||
node_modules
|
||||
|
12
.editorconfig
Normal file
12
.editorconfig
Normal 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
10
.env.tpl
Normal 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
|
@@ -1,2 +1,3 @@
|
||||
flow-typed
|
||||
tests-e2e
|
||||
build
|
||||
dll
|
||||
node_modules
|
||||
|
221
.eslintrc
221
.eslintrc
@@ -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
173
.eslintrc.js
Normal 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: '^_',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
19
.flowconfig
19
.flowconfig
@@ -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
2
.gitignore
vendored
@@ -1,5 +1,5 @@
|
||||
node_modules
|
||||
/dist
|
||||
/build
|
||||
/dll
|
||||
config/*
|
||||
!config/template.*
|
||||
|
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
build
|
||||
dll
|
||||
*.jpg
|
||||
*.png
|
||||
*.gif
|
||||
*.svg
|
6
.prettierrc
Normal file
6
.prettierrc
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"proseWrap": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
3
.storybook/addons.js
Normal file
3
.storybook/addons.js
Normal 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
13
.storybook/config.tsx
Normal 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);
|
12
.storybook/storyDecorator.js
Normal file
12
.storybook/storyDecorator.js
Normal 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>
|
||||
);
|
14
.storybook/webpack.config.js
Normal file
14
.storybook/webpack.config.js
Normal 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,
|
||||
});
|
33
.travis.yml
33
.travis.yml
@@ -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
419
@types/chalk.d.ts
vendored
Normal 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
61
@types/webpack-loaders.d.ts
vendored
Normal 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;
|
||||
}
|
18
README.md
18
README.md
@@ -3,7 +3,8 @@
|
||||
[](https://travis-ci.org/elyby/accounts-frontend)
|
||||
[](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
42
babel.config.js
Normal 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,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
@@ -1,3 +0,0 @@
|
||||
# https://github.com/ai/browserslist#config-file
|
||||
|
||||
Last 2 versions
|
14
config.js
Normal file
14
config.js
Normal 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,
|
||||
};
|
@@ -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
29
flow-typed/Promise.js
vendored
@@ -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>;
|
||||
}
|
265
flow-typed/npm/react-intl_v2.x.x.js
vendored
265
flow-typed/npm/react-intl_v2.x.x.js
vendored
@@ -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;
|
||||
}
|
123
flow-typed/npm/react-motion_vx.x.x.js
vendored
123
flow-typed/npm/react-motion_vx.x.x.js
vendored
@@ -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'>;
|
||||
}
|
114
flow-typed/npm/react-redux_v5.x.x.js
vendored
114
flow-typed/npm/react-redux_v5.x.x.js
vendored
@@ -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>;
|
||||
}
|
139
flow-typed/npm/react-router_v4.x.x.js
vendored
139
flow-typed/npm/react-router_v4.x.x.js
vendored
@@ -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;
|
||||
}
|
109
flow-typed/npm/redux_v3.x.x.js
vendored
109
flow-typed/npm/redux_v3.x.x.js
vendored
@@ -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
|
||||
|
||||
}
|
19
jest/__mocks__/intlMock.js
Normal file
19
jest/__mocks__/intlMock.js
Normal 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}/../../..`));
|
||||
},
|
||||
};
|
1
jest/__mocks__/mockStrExport.js
Normal file
1
jest/__mocks__/mockStrExport.js
Normal file
@@ -0,0 +1 @@
|
||||
module.exports = 'test-file-stub';
|
23
jest/setupAfterEnv.js
Normal file
23
jest/setupAfterEnv.js
Normal 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,
|
||||
};
|
||||
}
|
@@ -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);
|
||||
};
|
247
package.json
247
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
72
packages/app/components/MeasureHeight.tsx
Normal file
72
packages/app/components/MeasureHeight.tsx
Normal 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);
|
||||
}
|
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"addAccount": "Add account",
|
||||
"goToEly": "Go to Ely.by profile",
|
||||
"logout": "Log out"
|
||||
}
|
201
packages/app/components/accounts/AccountSwitcher.tsx
Normal file
201
packages/app/components/accounts/AccountSwitcher.tsx
Normal 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);
|
225
packages/app/components/accounts/accountSwitcher.scss
Normal file
225
packages/app/components/accounts/accountSwitcher.scss
Normal 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;
|
||||
}
|
||||
}
|
497
packages/app/components/accounts/actions.test.ts
Normal file
497
packages/app/components/accounts/actions.test.ts
Normal 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 },
|
||||
]));
|
||||
});
|
||||
});
|
||||
});
|
370
packages/app/components/accounts/actions.ts
Normal file
370
packages/app/components/accounts/actions.ts
Normal 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();
|
||||
};
|
||||
}
|
78
packages/app/components/accounts/actions/pure-actions.ts
Normal file
78
packages/app/components/accounts/actions/pure-actions.ts
Normal 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,
|
||||
};
|
||||
}
|
5
packages/app/components/accounts/index.ts
Normal file
5
packages/app/components/accounts/index.ts
Normal 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;
|
162
packages/app/components/accounts/reducer.test.ts
Normal file
162
packages/app/components/accounts/reducer.test.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
136
packages/app/components/accounts/reducer.ts
Normal file
136
packages/app/components/accounts/reducer.ts
Normal 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;
|
||||
}
|
16
packages/app/components/auth/AuthTitle.tsx
Normal file
16
packages/app/components/auth/AuthTitle.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Helmet } from 'react-helmet-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>
|
||||
);
|
||||
}
|
68
packages/app/components/auth/BaseAuthBody.tsx
Normal file
68
packages/app/components/auth/BaseAuthBody.tsx
Normal 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;
|
34
packages/app/components/auth/Context.tsx
Normal file
34
packages/app/components/auth/Context.tsx
Normal 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;
|
637
packages/app/components/auth/PanelTransition.tsx
Normal file
637
packages/app/components/auth/PanelTransition.tsx
Normal 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);
|
17
packages/app/components/auth/README.md
Normal file
17
packages/app/components/auth/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# How to add new auth panel
|
||||
|
||||
To add new panel you need to:
|
||||
|
||||
- create panel component at `components/auth/[panelId]`
|
||||
- add new context in `components/auth/PanelTransition`
|
||||
- connect component to router in `pages/auth/AuthPage`
|
||||
- add new state to `services/authFlow` and coresponding test to
|
||||
`tests/services/authFlow`
|
||||
- connect state to `authFlow`. Update `services/authFlow/AuthFlow.test` and
|
||||
`services/authFlow/AuthFlow.functional.test` (the last one for some complex
|
||||
flow)
|
||||
- add new actions to `components/auth/actions` and api endpoints to
|
||||
`services/api`
|
||||
- whatever else you need
|
||||
|
||||
Commit id with example implementation: f4d315c
|
37
packages/app/components/auth/RejectionLink.tsx
Normal file
37
packages/app/components/auth/RejectionLink.tsx
Normal 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;
|
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"title": "User Agreement",
|
||||
"accept": "Accept",
|
||||
"declineAndLogout": "Decline and logout",
|
||||
"description1": "We have updated our {link}.",
|
||||
"termsOfService": "terms of service",
|
||||
"description2": "In order to continue using {name} service, you need to accept them."
|
||||
}
|
16
packages/app/components/auth/acceptRules/AcceptRules.ts
Normal file
16
packages/app/components/auth/acceptRules/AcceptRules.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import factory from '../factory';
|
||||
import Body from './AcceptRulesBody';
|
||||
import messages from './AcceptRules.intl.json';
|
||||
|
||||
export default factory({
|
||||
title: messages.title,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'darkBlue',
|
||||
autoFocus: true,
|
||||
label: messages.accept,
|
||||
},
|
||||
links: {
|
||||
label: messages.declineAndLogout,
|
||||
},
|
||||
});
|
48
packages/app/components/auth/acceptRules/AcceptRulesBody.js
Normal file
48
packages/app/components/auth/acceptRules/AcceptRulesBody.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import icons from 'app/components/ui/icons.scss';
|
||||
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
|
||||
import appInfo from 'app/components/auth/appInfo/AppInfo.intl.json';
|
||||
|
||||
import styles from './acceptRules.scss';
|
||||
import messages from './AcceptRules.intl.json';
|
||||
|
||||
export default class AcceptRulesBody extends BaseAuthBody {
|
||||
static displayName = 'AcceptRulesBody';
|
||||
static panelId = 'acceptRules';
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<div className={styles.security}>
|
||||
<span className={icons.lock} />
|
||||
</div>
|
||||
|
||||
<p className={styles.descriptionText}>
|
||||
<Message
|
||||
{...messages.description1}
|
||||
values={{
|
||||
link: (
|
||||
<Link to="/rules" target="_blank">
|
||||
<Message {...messages.termsOfService} />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<br />
|
||||
<Message
|
||||
{...messages.description2}
|
||||
values={{
|
||||
name: <Message {...appInfo.appName} />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
16
packages/app/components/auth/acceptRules/acceptRules.scss
Normal file
16
packages/app/components/auth/acceptRules/acceptRules.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
|
||||
.descriptionText {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
padding-bottom: 8px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
// TODO: вынести иконки такого типа в какую-то внешнюю структуру?
|
||||
.security {
|
||||
color: #fff;
|
||||
font-size: 90px;
|
||||
line-height: 1;
|
||||
margin-bottom: 15px;
|
||||
}
|
194
packages/app/components/auth/actions.test.ts
Normal file
194
packages/app/components/auth/actions.test.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import sinon from 'sinon';
|
||||
import expect from 'app/test/unexpected';
|
||||
|
||||
import request from 'app/services/request';
|
||||
|
||||
import {
|
||||
setLoadingState,
|
||||
oAuthValidate,
|
||||
oAuthComplete,
|
||||
setClient,
|
||||
setOAuthRequest,
|
||||
setScopes,
|
||||
setOAuthCode,
|
||||
requirePermissionsAccept,
|
||||
login,
|
||||
setLogin,
|
||||
} from 'app/components/auth/actions';
|
||||
|
||||
const oauthData = {
|
||||
clientId: '',
|
||||
redirectUrl: '',
|
||||
responseType: '',
|
||||
scope: '',
|
||||
state: '',
|
||||
};
|
||||
|
||||
describe('components/auth/actions', () => {
|
||||
const dispatch = sinon.stub().named('store.dispatch');
|
||||
const getState = sinon.stub().named('store.getState');
|
||||
|
||||
function callThunk(fn, ...args) {
|
||||
const thunk = fn(...args);
|
||||
|
||||
return thunk(dispatch, getState);
|
||||
}
|
||||
|
||||
function expectDispatchCalls(calls) {
|
||||
expect(
|
||||
dispatch,
|
||||
'to have calls satisfying',
|
||||
[[setLoadingState(true)]]
|
||||
.concat(calls)
|
||||
.concat([[setLoadingState(false)]]),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch.reset();
|
||||
getState.reset();
|
||||
getState.returns({});
|
||||
sinon.stub(request, 'get').named('request.get');
|
||||
sinon.stub(request, 'post').named('request.post');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(request.get as any).restore();
|
||||
(request.post as any).restore();
|
||||
});
|
||||
|
||||
describe('#oAuthValidate()', () => {
|
||||
let resp;
|
||||
|
||||
beforeEach(() => {
|
||||
resp = {
|
||||
client: { id: 123 },
|
||||
oAuth: { state: 123 },
|
||||
session: {
|
||||
scopes: ['scopes'],
|
||||
},
|
||||
};
|
||||
|
||||
(request.get as any).returns(Promise.resolve(resp));
|
||||
});
|
||||
|
||||
it('should send get request to an api', () =>
|
||||
callThunk(oAuthValidate, oauthData).then(() => {
|
||||
expect(request.get, 'to have a call satisfying', [
|
||||
'/api/oauth2/v1/validate',
|
||||
{},
|
||||
]);
|
||||
}));
|
||||
|
||||
it('should dispatch setClient, setOAuthRequest and setScopes', () =>
|
||||
callThunk(oAuthValidate, oauthData).then(() => {
|
||||
expectDispatchCalls([
|
||||
[setClient(resp.client)],
|
||||
[
|
||||
setOAuthRequest({
|
||||
...resp.oAuth,
|
||||
prompt: 'none',
|
||||
loginHint: undefined,
|
||||
}),
|
||||
],
|
||||
[setScopes(resp.session.scopes)],
|
||||
]);
|
||||
}));
|
||||
});
|
||||
|
||||
describe('#oAuthComplete()', () => {
|
||||
beforeEach(() => {
|
||||
getState.returns({
|
||||
auth: {
|
||||
oauth: oauthData,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should post to api/oauth2/complete', () => {
|
||||
(request.post as any).returns(
|
||||
Promise.resolve({
|
||||
redirectUri: '',
|
||||
}),
|
||||
);
|
||||
|
||||
return callThunk(oAuthComplete).then(() => {
|
||||
expect(request.post, 'to have a call satisfying', [
|
||||
'/api/oauth2/v1/complete?client_id=&redirect_uri=&response_type=&description=&scope=&prompt=&login_hint=&state=',
|
||||
{},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch setOAuthCode for static_page redirect', () => {
|
||||
const resp = {
|
||||
success: true,
|
||||
redirectUri: 'static_page?code=123&state=',
|
||||
};
|
||||
|
||||
(request.post as any).returns(Promise.resolve(resp));
|
||||
|
||||
return callThunk(oAuthComplete).then(() => {
|
||||
expectDispatchCalls([
|
||||
[
|
||||
setOAuthCode({
|
||||
success: true,
|
||||
code: '123',
|
||||
displayCode: false,
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('should resolve to with success false and redirectUri for access_denied', async () => {
|
||||
const resp = {
|
||||
statusCode: 401,
|
||||
error: 'access_denied',
|
||||
redirectUri: 'redirectUri',
|
||||
};
|
||||
|
||||
(request.post as any).returns(Promise.reject(resp));
|
||||
|
||||
const data = await callThunk(oAuthComplete);
|
||||
|
||||
expect(data, 'to equal', {
|
||||
success: false,
|
||||
redirectUri: 'redirectUri',
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch requirePermissionsAccept if accept_required', () => {
|
||||
const resp = {
|
||||
statusCode: 401,
|
||||
error: 'accept_required',
|
||||
};
|
||||
|
||||
(request.post as any).returns(Promise.reject(resp));
|
||||
|
||||
return callThunk(oAuthComplete).catch(error => {
|
||||
expect(error.acceptRequired, 'to be true');
|
||||
expectDispatchCalls([[requirePermissionsAccept()]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#login()', () => {
|
||||
describe('when correct login was entered', () => {
|
||||
beforeEach(() => {
|
||||
(request.post as any).returns(
|
||||
Promise.reject({
|
||||
errors: {
|
||||
password: 'error.password_required',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set login', () =>
|
||||
callThunk(login, { login: 'foo' }).then(() => {
|
||||
expectDispatchCalls([[setLogin('foo')]]);
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
650
packages/app/components/auth/actions.ts
Normal file
650
packages/app/components/auth/actions.ts
Normal 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);
|
||||
};
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"accountActivationTitle": "Account activation",
|
||||
"activationMailWasSent": "Please check {email} for the message with further instructions",
|
||||
"activationMailWasSentNoEmail": "Please check your E‑mail for the message with further instructions",
|
||||
"confirmEmail": "Confirm E‑mail",
|
||||
"didNotReceivedEmail": "Did not received E‑mail?",
|
||||
"enterTheCode": "Enter the code from E‑mail here"
|
||||
}
|
15
packages/app/components/auth/activation/Activation.ts
Normal file
15
packages/app/components/auth/activation/Activation.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import factory from '../factory';
|
||||
import messages from './Activation.intl.json';
|
||||
import Body from './ActivationBody';
|
||||
|
||||
export default factory({
|
||||
title: messages.accountActivationTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'blue',
|
||||
label: messages.confirmEmail,
|
||||
},
|
||||
links: {
|
||||
label: messages.didNotReceivedEmail,
|
||||
},
|
||||
});
|
66
packages/app/components/auth/activation/ActivationBody.js
Normal file
66
packages/app/components/auth/activation/ActivationBody.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
|
||||
import { Input } from 'app/components/ui/form';
|
||||
|
||||
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
|
||||
import styles from './activation.scss';
|
||||
import messages from './Activation.intl.json';
|
||||
|
||||
export default class ActivationBody extends BaseAuthBody {
|
||||
static displayName = 'ActivationBody';
|
||||
static panelId = 'activation';
|
||||
|
||||
static propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
key: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
autoFocusField =
|
||||
this.props.match.params && this.props.match.params.key ? null : 'key';
|
||||
|
||||
render() {
|
||||
const { key } = this.props.match.params;
|
||||
const { email } = this.context.user;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<div className={styles.description}>
|
||||
<div className={styles.descriptionImage} />
|
||||
|
||||
<div className={styles.descriptionText}>
|
||||
{email ? (
|
||||
<Message
|
||||
{...messages.activationMailWasSent}
|
||||
values={{
|
||||
email: <b>{email}</b>,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Message {...messages.activationMailWasSentNoEmail} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...this.bindField('key')}
|
||||
color="blue"
|
||||
center
|
||||
required
|
||||
value={key}
|
||||
readOnly={!!key}
|
||||
autoComplete="off"
|
||||
placeholder={messages.enterTheCode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
19
packages/app/components/auth/activation/activation.scss
Normal file
19
packages/app/components/auth/activation/activation.scss
Normal file
@@ -0,0 +1,19 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
@import '~app/components/ui/fonts.scss';
|
||||
|
||||
.description {
|
||||
}
|
||||
|
||||
.descriptionImage {
|
||||
composes: envelope from '~app/components/ui/icons.scss';
|
||||
|
||||
font-size: 100px;
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
.descriptionText {
|
||||
font-family: $font-family-title;
|
||||
margin: 5px 0 19px;
|
||||
line-height: 1.4;
|
||||
font-size: 16px;
|
||||
}
|
7
packages/app/components/auth/appInfo/AppInfo.intl.json
Normal file
7
packages/app/components/auth/appInfo/AppInfo.intl.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"appName": "Ely Accounts",
|
||||
"goToAuth": "Go to auth",
|
||||
"appDescription": "You are on the Ely.by authorization service, that allows you to safely perform any operations on your account. This single entry point for websites and desktop software, including game launchers.",
|
||||
"useItYourself": "Visit our {link}, to learn how to use this service in you projects.",
|
||||
"documentation": "documentation"
|
||||
}
|
57
packages/app/components/auth/appInfo/AppInfo.tsx
Normal file
57
packages/app/components/auth/appInfo/AppInfo.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { Button } from 'app/components/ui/form';
|
||||
import { FooterMenu } from 'app/components/footerMenu';
|
||||
|
||||
import styles from './appInfo.scss';
|
||||
import messages from './AppInfo.intl.json';
|
||||
|
||||
export default class AppInfo extends React.Component<{
|
||||
name?: string;
|
||||
description?: string;
|
||||
onGoToAuth: () => void;
|
||||
}> {
|
||||
render() {
|
||||
const { name, description, onGoToAuth } = this.props;
|
||||
|
||||
return (
|
||||
<div className={styles.appInfo}>
|
||||
<div className={styles.logoContainer}>
|
||||
<h2 className={styles.logo}>
|
||||
{name ? name : <Message {...messages.appName} />}
|
||||
</h2>
|
||||
</div>
|
||||
<div className={styles.descriptionContainer}>
|
||||
{description ? (
|
||||
<p className={styles.description}>{description}</p>
|
||||
) : (
|
||||
<div>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.appDescription} />
|
||||
</p>
|
||||
<p className={styles.description}>
|
||||
<Message
|
||||
{...messages.useItYourself}
|
||||
values={{
|
||||
link: (
|
||||
<a href="http://docs.ely.by/oauth.html">
|
||||
<Message {...messages.documentation} />
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.goToAuth}>
|
||||
<Button onClick={onGoToAuth} label={messages.goToAuth} />
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<FooterMenu />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
72
packages/app/components/auth/appInfo/appInfo.scss
Normal file
72
packages/app/components/auth/appInfo/appInfo.scss
Normal file
@@ -0,0 +1,72 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
@import '~app/components/ui/fonts.scss';
|
||||
|
||||
.appInfo {
|
||||
max-width: 270px;
|
||||
margin: 0 auto;
|
||||
padding: 55px 25px;
|
||||
}
|
||||
|
||||
.logoContainer {
|
||||
position: relative;
|
||||
padding: 15px 0;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
width: 40px;
|
||||
|
||||
background: $green;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: $font-family-title;
|
||||
color: #fff;
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.descriptionContainer {
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
$font-color: #ccc;
|
||||
font-family: $font-family-base;
|
||||
color: $font-color;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
margin-top: 7px;
|
||||
|
||||
a {
|
||||
color: lighten($font-color, 10%);
|
||||
border-bottom-color: #666;
|
||||
|
||||
&:hover {
|
||||
color: $font-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goToAuth {
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.goToAuth {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
3
packages/app/components/auth/auth.scss
Normal file
3
packages/app/components/auth/auth.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.checkboxInput {
|
||||
margin-top: 15px;
|
||||
}
|
45
packages/app/components/auth/authError/AuthError.js
Normal file
45
packages/app/components/auth/authError/AuthError.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import errorsDict from 'app/services/errorsDict';
|
||||
import { PanelBodyHeader } from 'app/components/ui/Panel';
|
||||
|
||||
let autoHideTimer;
|
||||
function resetTimer() {
|
||||
if (autoHideTimer) {
|
||||
clearTimeout(autoHideTimer);
|
||||
autoHideTimer = null;
|
||||
}
|
||||
}
|
||||
export default function AuthError({ error, onClose = function() {} }) {
|
||||
resetTimer();
|
||||
|
||||
if (error.payload && error.payload.canRepeatIn) {
|
||||
error.payload.msLeft = error.payload.canRepeatIn * 1000;
|
||||
setTimeout(onClose, error.payload.msLeft - Date.now() + 1500); // 1500 to let the user see, that time is elapsed
|
||||
}
|
||||
|
||||
return (
|
||||
<PanelBodyHeader
|
||||
type="error"
|
||||
onClose={() => {
|
||||
resetTimer();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{errorsDict.resolve(error)}
|
||||
</PanelBodyHeader>
|
||||
);
|
||||
}
|
||||
|
||||
AuthError.displayName = 'AuthError';
|
||||
AuthError.propTypes = {
|
||||
error: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
payload: PropTypes.object,
|
||||
}),
|
||||
]).isRequired,
|
||||
onClose: PropTypes.func,
|
||||
};
|
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"chooseAccountTitle": "Choose an account",
|
||||
"addAccount": "Log into another account",
|
||||
"logoutAll": "Log out from all accounts",
|
||||
"pleaseChooseAccount": "Please select an account you're willing to use",
|
||||
"pleaseChooseAccountForApp": "Please select an account that you want to use to authorize {appName}"
|
||||
}
|
16
packages/app/components/auth/chooseAccount/ChooseAccount.ts
Normal file
16
packages/app/components/auth/chooseAccount/ChooseAccount.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import factory from '../factory';
|
||||
import messages from './ChooseAccount.intl.json';
|
||||
import Body from './ChooseAccountBody';
|
||||
|
||||
export default factory({
|
||||
title: messages.chooseAccountTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
label: messages.addAccount,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
label: messages.logoutAll,
|
||||
},
|
||||
],
|
||||
});
|
@@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
|
||||
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
|
||||
import { AccountSwitcher } from 'app/components/accounts';
|
||||
|
||||
import styles from './chooseAccount.scss';
|
||||
import messages from './ChooseAccount.intl.json';
|
||||
|
||||
export default class ChooseAccountBody extends BaseAuthBody {
|
||||
static displayName = 'ChooseAccountBody';
|
||||
static panelId = 'chooseAccount';
|
||||
|
||||
render() {
|
||||
const { client } = this.context.auth;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<div className={styles.description}>
|
||||
{client ? (
|
||||
<Message
|
||||
{...messages.pleaseChooseAccountForApp}
|
||||
values={{
|
||||
appName: <span className={styles.appName}>{client.name}</span>,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.description}>
|
||||
<Message {...messages.pleaseChooseAccount} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.accountSwitcherContainer}>
|
||||
<AccountSwitcher
|
||||
allowAdd={false}
|
||||
allowLogout={false}
|
||||
highlightActiveAccount={false}
|
||||
onSwitch={this.onSwitch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onSwitch = account => {
|
||||
this.context.resolve(account);
|
||||
};
|
||||
}
|
@@ -0,0 +1,18 @@
|
||||
@import '~app/components/ui/panel.scss';
|
||||
@import '~app/components/ui/fonts.scss';
|
||||
|
||||
.accountSwitcherContainer {
|
||||
margin-left: -$bodyLeftRightPadding;
|
||||
margin-right: -$bodyLeftRightPadding;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-family: $font-family-title;
|
||||
margin: 5px 0 19px;
|
||||
line-height: 1.4;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.appName {
|
||||
color: #fff;
|
||||
}
|
50
packages/app/components/auth/factory.tsx
Normal file
50
packages/app/components/auth/factory.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import { Button } from 'app/components/ui/form';
|
||||
import RejectionLink, {
|
||||
RejectionLinkProps,
|
||||
} from 'app/components/auth/RejectionLink';
|
||||
import AuthTitle from 'app/components/auth/AuthTitle';
|
||||
import { MessageDescriptor } from 'react-intl';
|
||||
import { Color } from 'app/components/ui';
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {string|object} options.title - panel title
|
||||
* @param {React.ReactElement} options.body
|
||||
* @param {object} options.footer - config for footer Button
|
||||
* @param {Array|object|null} options.links - link config or an array of link configs
|
||||
*
|
||||
* @returns {object} - structure, required for auth panel to work
|
||||
*/
|
||||
export default function({
|
||||
title,
|
||||
body,
|
||||
footer,
|
||||
links,
|
||||
}: {
|
||||
title: MessageDescriptor;
|
||||
body: React.ElementType;
|
||||
footer: {
|
||||
color?: Color;
|
||||
label: string | MessageDescriptor;
|
||||
autoFocus?: boolean;
|
||||
};
|
||||
links?: RejectionLinkProps | RejectionLinkProps[];
|
||||
}) {
|
||||
return () => ({
|
||||
Title: () => <AuthTitle title={title} />,
|
||||
Body: body,
|
||||
Footer: () => <Button type="submit" {...footer} />,
|
||||
Links: () =>
|
||||
links ? (
|
||||
<span>
|
||||
{([] as RejectionLinkProps[])
|
||||
.concat(links)
|
||||
.map((link, index) => [
|
||||
index ? ' | ' : '',
|
||||
<RejectionLink {...link} key={index} />,
|
||||
])}
|
||||
</span>
|
||||
) : null,
|
||||
});
|
||||
}
|
7
packages/app/components/auth/finish/Finish.intl.json
Normal file
7
packages/app/components/auth/finish/Finish.intl.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"authForAppSuccessful": "Authorization for {appName} was successfully completed",
|
||||
"authForAppFailed": "Authorization for {appName} was failed",
|
||||
"waitAppReaction": "Please, wait till your application response",
|
||||
"passCodeToApp": "To complete authorization process, please, provide the following code to {appName}",
|
||||
"copy": "Copy"
|
||||
}
|
110
packages/app/components/auth/finish/Finish.tsx
Normal file
110
packages/app/components/auth/finish/Finish.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { Helmet } from 'react-helmet-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);
|
76
packages/app/components/auth/finish/finish.scss
Normal file
76
packages/app/components/auth/finish/finish.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
@import '~app/components/ui/fonts.scss';
|
||||
|
||||
.finishPage {
|
||||
font-family: $font-family-title;
|
||||
position: relative;
|
||||
max-width: 515px;
|
||||
padding-top: 40px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.iconBackground {
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 200px;
|
||||
color: #e0d9cf;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.successBackground {
|
||||
composes: checkmark from '~app/components/ui/icons.scss';
|
||||
@extend .iconBackground;
|
||||
}
|
||||
|
||||
.failBackground {
|
||||
composes: close from '~app/components/ui/icons.scss';
|
||||
@extend .iconBackground;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.greenTitle {
|
||||
composes: title;
|
||||
|
||||
color: $green;
|
||||
|
||||
.appName {
|
||||
color: darker($green);
|
||||
}
|
||||
}
|
||||
|
||||
.redTitle {
|
||||
composes: title;
|
||||
|
||||
color: $red;
|
||||
|
||||
.appName {
|
||||
color: darker($red);
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.codeContainer {
|
||||
margin-bottom: 5px;
|
||||
margin-top: 35px;
|
||||
}
|
||||
|
||||
.code {
|
||||
$border: 5px solid darker($green);
|
||||
|
||||
display: inline-block;
|
||||
border-right: $border;
|
||||
border-left: $border;
|
||||
padding: 5px 10px;
|
||||
word-break: break-all;
|
||||
text-align: center;
|
||||
}
|
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"title": "Forgot password",
|
||||
"sendMail": "Send mail",
|
||||
"specifyEmail": "Specify the registration E‑mail address or last used username for your account and we will send an E‑mail with instructions for further password recovery.",
|
||||
"pleasePressButton": "Please press the button bellow to get an E‑mail with password recovery code.",
|
||||
"alreadyHaveCode": "Already have a code"
|
||||
}
|
@@ -0,0 +1,16 @@
|
||||
import factory from '../factory';
|
||||
import messages from './ForgotPassword.intl.json';
|
||||
import Body from './ForgotPasswordBody';
|
||||
|
||||
export default factory({
|
||||
title: messages.title,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'lightViolet',
|
||||
autoFocus: true,
|
||||
label: messages.sendMail,
|
||||
},
|
||||
links: {
|
||||
label: messages.alreadyHaveCode,
|
||||
},
|
||||
});
|
@@ -0,0 +1,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');
|
||||
};
|
||||
}
|
@@ -0,0 +1,31 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
|
||||
.descriptionText {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
padding-bottom: 8px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.login {
|
||||
composes: email from '~app/components/auth/password/password.scss';
|
||||
}
|
||||
|
||||
.editLogin {
|
||||
composes: pencil from '~app/components/ui/icons.scss';
|
||||
|
||||
position: relative;
|
||||
bottom: 1px;
|
||||
padding-left: 3px;
|
||||
|
||||
color: #666666;
|
||||
font-size: 10px;
|
||||
|
||||
transition: color 0.3s;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
9
packages/app/components/auth/helpLinks.scss
Normal file
9
packages/app/components/auth/helpLinks.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
.helpLinks {
|
||||
margin: 8px 0;
|
||||
position: relative;
|
||||
height: 20px;
|
||||
|
||||
color: #444;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
}
|
3
packages/app/components/auth/index.ts
Normal file
3
packages/app/components/auth/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { State } from './reducer';
|
||||
|
||||
export type AuthState = State;
|
6
packages/app/components/auth/login/Login.intl.json
Normal file
6
packages/app/components/auth/login/Login.intl.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"createNewAccount": "Create new account",
|
||||
"loginTitle": "Sign in",
|
||||
"emailOrUsername": "E‑mail or username",
|
||||
"next": "Next"
|
||||
}
|
16
packages/app/components/auth/login/Login.ts
Normal file
16
packages/app/components/auth/login/Login.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import factory from '../factory';
|
||||
import Body from './LoginBody';
|
||||
import messages from './Login.intl.json';
|
||||
|
||||
export default factory({
|
||||
title: messages.loginTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'green',
|
||||
label: messages.next,
|
||||
},
|
||||
links: {
|
||||
isAvailable: context => !context.user.isGuest,
|
||||
label: messages.createNewAccount,
|
||||
},
|
||||
});
|
30
packages/app/components/auth/login/LoginBody.js
Normal file
30
packages/app/components/auth/login/LoginBody.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { Input } from 'app/components/ui/form';
|
||||
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
|
||||
|
||||
import messages from './Login.intl.json';
|
||||
|
||||
export default class LoginBody extends BaseAuthBody {
|
||||
static displayName = 'LoginBody';
|
||||
static panelId = 'login';
|
||||
static hasGoBack = state => {
|
||||
return !state.user.isGuest;
|
||||
};
|
||||
|
||||
autoFocusField = 'login';
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<Input
|
||||
{...this.bindField('login')}
|
||||
icon="envelope"
|
||||
required
|
||||
placeholder={messages.emailOrUsername}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
4
packages/app/components/auth/mfa/Mfa.intl.json
Normal file
4
packages/app/components/auth/mfa/Mfa.intl.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"enterTotp": "Enter code",
|
||||
"description": "In order to sign in this account, you need to enter a one-time password from mobile application"
|
||||
}
|
13
packages/app/components/auth/mfa/Mfa.tsx
Normal file
13
packages/app/components/auth/mfa/Mfa.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import factory from '../factory';
|
||||
import Body from './MfaBody';
|
||||
import messages from './Mfa.intl.json';
|
||||
import passwordMessages from '../password/Password.intl.json';
|
||||
|
||||
export default factory({
|
||||
title: messages.enterTotp,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'green',
|
||||
label: passwordMessages.signInButton,
|
||||
},
|
||||
});
|
37
packages/app/components/auth/mfa/MfaBody.tsx
Normal file
37
packages/app/components/auth/mfa/MfaBody.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { PanelIcon } from 'app/components/ui/Panel';
|
||||
import { Input } from 'app/components/ui/form';
|
||||
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
|
||||
|
||||
import styles from './mfa.scss';
|
||||
import messages from './Mfa.intl.json';
|
||||
|
||||
export default class MfaBody extends BaseAuthBody {
|
||||
static panelId = 'mfa';
|
||||
static hasGoBack = true;
|
||||
|
||||
autoFocusField = 'totp';
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<PanelIcon icon="lock" />
|
||||
|
||||
<p className={styles.descriptionText}>
|
||||
<Message {...messages.description} />
|
||||
</p>
|
||||
|
||||
<Input
|
||||
{...this.bindField('totp')}
|
||||
icon="key"
|
||||
required
|
||||
placeholder={messages.enterTotp}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
6
packages/app/components/auth/mfa/mfa.scss
Normal file
6
packages/app/components/auth/mfa/mfa.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.descriptionText {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
padding-bottom: 8px;
|
||||
color: #aaa;
|
||||
}
|
7
packages/app/components/auth/password/Password.intl.json
Normal file
7
packages/app/components/auth/password/Password.intl.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"passwordTitle": "Enter password",
|
||||
"signInButton": "Sign in",
|
||||
"forgotPassword": "Forgot password",
|
||||
"accountPassword": "Account password",
|
||||
"rememberMe": "Remember me on this device"
|
||||
}
|
15
packages/app/components/auth/password/Password.ts
Normal file
15
packages/app/components/auth/password/Password.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import factory from '../factory';
|
||||
import Body from './PasswordBody';
|
||||
import messages from './Password.intl.json';
|
||||
|
||||
export default factory({
|
||||
title: messages.passwordTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'green',
|
||||
label: messages.signInButton,
|
||||
},
|
||||
links: {
|
||||
label: messages.forgotPassword,
|
||||
},
|
||||
});
|
53
packages/app/components/auth/password/PasswordBody.js
Normal file
53
packages/app/components/auth/password/PasswordBody.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import icons from 'app/components/ui/icons.scss';
|
||||
import { Input, Checkbox } from 'app/components/ui/form';
|
||||
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
|
||||
import authStyles from 'app/components/auth/auth.scss';
|
||||
|
||||
import styles from './password.scss';
|
||||
import messages from './Password.intl.json';
|
||||
|
||||
export default class PasswordBody extends BaseAuthBody {
|
||||
static displayName = 'PasswordBody';
|
||||
static panelId = 'password';
|
||||
static hasGoBack = true;
|
||||
|
||||
autoFocusField = 'password';
|
||||
|
||||
render() {
|
||||
const { user } = this.context;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<div className={styles.miniProfile}>
|
||||
<div className={styles.avatar}>
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} />
|
||||
) : (
|
||||
<span className={icons.user} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.email}>{user.email || user.username}</div>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
{...this.bindField('password')}
|
||||
icon="key"
|
||||
type="password"
|
||||
required
|
||||
placeholder={messages.accountPassword}
|
||||
/>
|
||||
|
||||
<div className={authStyles.checkboxInput}>
|
||||
<Checkbox
|
||||
{...this.bindField('rememberMe')}
|
||||
defaultChecked
|
||||
label={messages.rememberMe}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
22
packages/app/components/auth/password/password.scss
Normal file
22
packages/app/components/auth/password/password.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
@import '~app/components/ui/fonts.scss';
|
||||
|
||||
.avatar {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
font-size: 90px;
|
||||
line-height: 1;
|
||||
margin: 0 auto;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.email {
|
||||
font-family: $font-family-title;
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
|
||||
margin-bottom: 15px;
|
||||
margin-top: 10px;
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"permissionsTitle": "Application permissions",
|
||||
"youAuthorizedAs": "You authorized as:",
|
||||
"theAppNeedsAccess1": "This application needs access",
|
||||
"theAppNeedsAccess2": "to your data",
|
||||
"decline": "Decline",
|
||||
"approve": "Approve",
|
||||
"scope_minecraft_server_session": "Authorization data for minecraft server",
|
||||
"scope_offline_access": "Access to your profile data, when you offline",
|
||||
"scope_account_info": "Access to your profile data (except E‑mail)",
|
||||
"scope_account_email": "Access to your E‑mail address"
|
||||
}
|
16
packages/app/components/auth/permissions/Permissions.ts
Normal file
16
packages/app/components/auth/permissions/Permissions.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import factory from '../factory';
|
||||
import messages from './Permissions.intl.json';
|
||||
import Body from './PermissionsBody';
|
||||
|
||||
export default factory({
|
||||
title: messages.permissionsTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'orange',
|
||||
autoFocus: true,
|
||||
label: messages.approve,
|
||||
},
|
||||
links: {
|
||||
label: messages.decline,
|
||||
},
|
||||
});
|
65
packages/app/components/auth/permissions/PermissionsBody.js
Normal file
65
packages/app/components/auth/permissions/PermissionsBody.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import icons from 'app/components/ui/icons.scss';
|
||||
import { PanelBodyHeader } from 'app/components/ui/Panel';
|
||||
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
|
||||
|
||||
import styles from './permissions.scss';
|
||||
import messages from './Permissions.intl.json';
|
||||
|
||||
export default class PermissionsBody extends BaseAuthBody {
|
||||
static displayName = 'PermissionsBody';
|
||||
static panelId = 'permissions';
|
||||
|
||||
render() {
|
||||
const { user } = this.context;
|
||||
const { scopes } = this.context.auth;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<PanelBodyHeader>
|
||||
<div className={styles.authInfo}>
|
||||
<div className={styles.authInfoAvatar}>
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} />
|
||||
) : (
|
||||
<span className={icons.user} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.authInfoTitle}>
|
||||
<Message {...messages.youAuthorizedAs} />
|
||||
</div>
|
||||
<div className={styles.authInfoEmail}>{user.username}</div>
|
||||
</div>
|
||||
</PanelBodyHeader>
|
||||
<div className={styles.permissionsContainer}>
|
||||
<div className={styles.permissionsTitle}>
|
||||
<Message {...messages.theAppNeedsAccess1} />
|
||||
<br />
|
||||
<Message {...messages.theAppNeedsAccess2} />
|
||||
</div>
|
||||
<ul className={styles.permissionsList}>
|
||||
{scopes.map(scope => {
|
||||
const key = `scope_${scope}`;
|
||||
const message = messages[key];
|
||||
|
||||
return (
|
||||
<li key={key}>
|
||||
{message ? (
|
||||
<Message {...message} />
|
||||
) : (
|
||||
scope.replace(/^\w|_/g, match =>
|
||||
match.replace('_', ' ').toUpperCase(),
|
||||
)
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
77
packages/app/components/auth/permissions/permissions.scss
Normal file
77
packages/app/components/auth/permissions/permissions.scss
Normal file
@@ -0,0 +1,77 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
@import '~app/components/ui/fonts.scss';
|
||||
|
||||
.authInfo {
|
||||
// Отступы сверху и снизу разные т.к. мы ужимаем высоту линии строки с логином на 2 пикселя и из-за этого теряем отступ снизу
|
||||
padding: 5px 20px 7px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.authInfoAvatar {
|
||||
$size: 30px;
|
||||
|
||||
float: left;
|
||||
height: $size;
|
||||
width: $size;
|
||||
font-size: $size;
|
||||
line-height: 1;
|
||||
margin-right: 10px;
|
||||
margin-top: 2px;
|
||||
color: #aaa;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.authInfoTitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.authInfoEmail {
|
||||
font-family: $font-family-title;
|
||||
font-size: 20px;
|
||||
line-height: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.permissionsContainer {
|
||||
padding: 15px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.permissionsTitle {
|
||||
font-family: $font-family-title;
|
||||
font-size: 18px;
|
||||
color: #dd8650;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.permissionsList {
|
||||
list-style: none;
|
||||
margin-top: 10px;
|
||||
|
||||
li {
|
||||
color: #a9a9a9;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
padding-bottom: 4px;
|
||||
padding-left: 17px;
|
||||
position: relative;
|
||||
|
||||
&:last-of-type {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: '• ';
|
||||
color: lighter($light_violet);
|
||||
font-size: 39px; // ~ 9px
|
||||
line-height: 9px;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: -4px;
|
||||
}
|
||||
}
|
||||
}
|
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"title": "Restore password",
|
||||
"contactSupport": "Contact support",
|
||||
"messageWasSent": "The recovery code was sent to your account E‑mail.",
|
||||
"messageWasSentTo": "The recovery code was sent to your E‑mail {email}.",
|
||||
"enterCodeBelow": "Please enter the code received into the field below:",
|
||||
"enterNewPasswordBelow": "Enter and repeat new password below:",
|
||||
"change": "Change password",
|
||||
"newPassword": "Enter new password",
|
||||
"newRePassword": "Repeat new password",
|
||||
"enterTheCode": "Enter confirmation code"
|
||||
}
|
@@ -0,0 +1,15 @@
|
||||
import factory from '../factory';
|
||||
import messages from './RecoverPassword.intl.json';
|
||||
import Body from './RecoverPasswordBody';
|
||||
|
||||
export default factory({
|
||||
title: messages.title,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'lightViolet',
|
||||
label: messages.change,
|
||||
},
|
||||
links: {
|
||||
label: messages.contactSupport,
|
||||
},
|
||||
});
|
@@ -0,0 +1,89 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
|
||||
import { Input } from 'app/components/ui/form';
|
||||
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
|
||||
|
||||
import styles from './recoverPassword.scss';
|
||||
import messages from './RecoverPassword.intl.json';
|
||||
|
||||
// TODO: activation code field may be decoupled into common component and reused here and in activation panel
|
||||
|
||||
export default class RecoverPasswordBody extends BaseAuthBody {
|
||||
static displayName = 'RecoverPasswordBody';
|
||||
static panelId = 'recoverPassword';
|
||||
static hasGoBack = true;
|
||||
|
||||
static propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
key: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
autoFocusField =
|
||||
this.props.match.params && this.props.match.params.key
|
||||
? 'newPassword'
|
||||
: 'key';
|
||||
|
||||
render() {
|
||||
const { user } = this.context;
|
||||
const { key } = this.props.match.params;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<p className={styles.descriptionText}>
|
||||
{user.maskedEmail ? (
|
||||
<Message
|
||||
{...messages.messageWasSentTo}
|
||||
values={{
|
||||
email: <b>{user.maskedEmail}</b>,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Message {...messages.messageWasSent} />
|
||||
)}{' '}
|
||||
<Message {...messages.enterCodeBelow} />
|
||||
</p>
|
||||
|
||||
<Input
|
||||
{...this.bindField('key')}
|
||||
color="lightViolet"
|
||||
center
|
||||
required
|
||||
value={key}
|
||||
readOnly={!!key}
|
||||
autoComplete="off"
|
||||
placeholder={messages.enterTheCode}
|
||||
/>
|
||||
|
||||
<p className={styles.descriptionText}>
|
||||
<Message {...messages.enterNewPasswordBelow} />
|
||||
</p>
|
||||
|
||||
<Input
|
||||
{...this.bindField('newPassword')}
|
||||
icon="key"
|
||||
color="lightViolet"
|
||||
type="password"
|
||||
required
|
||||
placeholder={messages.newPassword}
|
||||
/>
|
||||
|
||||
<Input
|
||||
{...this.bindField('newRePassword')}
|
||||
icon="key"
|
||||
color="lightViolet"
|
||||
type="password"
|
||||
required
|
||||
placeholder={messages.newRePassword}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@@ -0,0 +1,8 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
|
||||
.descriptionText {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
color: #aaa;
|
||||
}
|
47
packages/app/components/auth/reducer.test.ts
Normal file
47
packages/app/components/auth/reducer.test.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import expect from 'app/test/unexpected';
|
||||
import auth from './reducer';
|
||||
import {
|
||||
setLogin,
|
||||
SET_CREDENTIALS,
|
||||
setAccountSwitcher,
|
||||
SET_SWITCHER,
|
||||
} from './actions';
|
||||
|
||||
describe('components/auth/reducer', () => {
|
||||
describe(SET_CREDENTIALS, () => {
|
||||
it('should set login', () => {
|
||||
const expectedLogin = 'foo';
|
||||
|
||||
expect(
|
||||
auth(undefined, setLogin(expectedLogin)).credentials,
|
||||
'to satisfy',
|
||||
{
|
||||
login: expectedLogin,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SET_SWITCHER, () => {
|
||||
it('should be enabled by default', () =>
|
||||
expect(auth(undefined, {} as any), 'to satisfy', {
|
||||
isSwitcherEnabled: true,
|
||||
}));
|
||||
|
||||
it('should enable switcher', () => {
|
||||
const expectedValue = true;
|
||||
|
||||
expect(auth(undefined, setAccountSwitcher(expectedValue)), 'to satisfy', {
|
||||
isSwitcherEnabled: expectedValue,
|
||||
});
|
||||
});
|
||||
|
||||
it('should disable switcher', () => {
|
||||
const expectedValue = false;
|
||||
|
||||
expect(auth(undefined, setAccountSwitcher(expectedValue)), 'to satisfy', {
|
||||
isSwitcherEnabled: expectedValue,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user