mirror of
https://github.com/elyby/accounts-frontend.git
synced 2025-05-31 14:11:58 +05:30
Add prettier and re-configure lint according to current best practises
This commit is contained in:
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
|
@@ -1,2 +1,4 @@
|
||||
flow-typed
|
||||
tests-e2e
|
||||
dist
|
||||
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"],
|
||||
}
|
||||
}
|
172
.eslintrc.js
Normal file
172
.eslintrc.js
Normal file
@@ -0,0 +1,172 @@
|
||||
module.exports = {
|
||||
parser: 'babel-eslint',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2018,
|
||||
sourceType: 'module',
|
||||
},
|
||||
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:flowtype/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'plugin:jsdoc/recommended',
|
||||
],
|
||||
|
||||
plugins: ['react', 'flowtype'],
|
||||
|
||||
env: {
|
||||
browser: true,
|
||||
es6: true,
|
||||
commonjs: true,
|
||||
},
|
||||
|
||||
overrides: [
|
||||
{
|
||||
files: ['webpack-utils/**', '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'],
|
||||
'no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
vars: 'all',
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'object-shorthand': ['warn'],
|
||||
|
||||
// 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-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/space-after-type-colon': 'off',
|
||||
'flowtype/no-unused-expressions': [
|
||||
'warn',
|
||||
{ allowShortCircuit: true, allowTernary: true },
|
||||
],
|
||||
},
|
||||
};
|
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
dist
|
||||
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"
|
||||
}
|
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.
|
||||
|
@@ -1,38 +1,38 @@
|
||||
module.exports = {
|
||||
presets: ['@babel/preset-react', '@babel/preset-flow', ['@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: './dist/messages/' }]
|
||||
presets: ['@babel/preset-react', '@babel/preset-flow', ['@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,
|
||||
},
|
||||
],
|
||||
env: {
|
||||
webpack: {
|
||||
plugins: ['react-hot-loader/babel'],
|
||||
presets: [
|
||||
[
|
||||
'@babel/preset-env',
|
||||
{
|
||||
modules: false,
|
||||
useBuiltIns: 'usage', // or "entry"
|
||||
corejs: 3
|
||||
}
|
||||
]
|
||||
]
|
||||
},
|
||||
development: {
|
||||
presets: []
|
||||
},
|
||||
test: {
|
||||
presets: []
|
||||
}
|
||||
}
|
||||
['react-intl', { messagesDir: './dist/messages/' }],
|
||||
],
|
||||
env: {
|
||||
webpack: {
|
||||
plugins: ['react-hot-loader/babel'],
|
||||
presets: [
|
||||
[
|
||||
'@babel/preset-env',
|
||||
{
|
||||
modules: false,
|
||||
useBuiltIns: 'usage', // or "entry"
|
||||
corejs: 3,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
development: {
|
||||
presets: [],
|
||||
},
|
||||
test: {
|
||||
presets: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
12
config.js
12
config.js
@@ -5,10 +5,10 @@ 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
|
||||
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: '',
|
||||
};
|
||||
|
@@ -1,6 +1,6 @@
|
||||
/* eslint-env node */
|
||||
const path = require("path");
|
||||
const { transform } = require("../../webpack-utils/intl-loader");
|
||||
const path = require('path');
|
||||
const { transform } = require('../../webpack-utils/intl-loader');
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
@@ -9,14 +9,10 @@ module.exports = {
|
||||
* @param {{[key: string]: any}} config - jest config
|
||||
* @param {{instrument: boolean}} options - additional options
|
||||
*
|
||||
* @return {string}
|
||||
* @returns {string}
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
process(src, filename, config, options) {
|
||||
return transform(
|
||||
src,
|
||||
filename,
|
||||
path.resolve(`${__dirname}/../../..`)
|
||||
);
|
||||
}
|
||||
return transform(src, filename, path.resolve(`${__dirname}/../../..`));
|
||||
},
|
||||
};
|
||||
|
@@ -5,19 +5,19 @@ 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.localStorage = {
|
||||
getItem(key) {
|
||||
return this[key] || null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
this[key] = value;
|
||||
},
|
||||
removeItem(key) {
|
||||
delete this[key];
|
||||
},
|
||||
};
|
||||
|
||||
window.sessionStorage = {
|
||||
...window.localStorage
|
||||
};
|
||||
window.sessionStorage = {
|
||||
...window.localStorage,
|
||||
};
|
||||
}
|
||||
|
13
package.json
13
package.json
@@ -29,9 +29,14 @@
|
||||
"clean": "rm -rf ./dist && mkdir ./dist",
|
||||
"e2e": "yarn --cwd ./tests-e2e test",
|
||||
"test": "NODE_PATH=./src jest",
|
||||
"test:watch": "yarn test --watch",
|
||||
"lint": "eslint --fix --quiet .",
|
||||
"lint:check": "eslint --quiet .",
|
||||
"prettier": "prettier --write \"{src/**/*,scripts/**/*,tests-e2e/**/*,webpack-utils/**/*,jest/**/*,config/**/*,*}.{js,ts,tsx,json,md,scss,css}\"",
|
||||
"prettier:check": "prettier --check \"{src/**/*,scripts/**/*,tests-e2e/**/*,webpack-utils/**/*,jest/**/*,config/**/*,*}.{js,ts,tsx,json,md,scss,css}\"",
|
||||
"flow:check": "flow",
|
||||
"ci:check": "yarn lint:check && yarn flow:check && yarn test",
|
||||
"analyze": "yarn run clean && yarn run build:webpack --analyze",
|
||||
"lint": "eslint ./src",
|
||||
"flow": "flow",
|
||||
"i18n:collect": "babel-node ./scripts/i18n-collect.js",
|
||||
"i18n:push": "babel-node ./scripts/i18n-crowdin.js push",
|
||||
"i18n:pull": "babel-node ./scripts/i18n-crowdin.js pull",
|
||||
@@ -132,7 +137,10 @@
|
||||
"enzyme": "^3.8.0",
|
||||
"enzyme-adapter-react-16": "^1.15.1",
|
||||
"eslint": "^6.7.1",
|
||||
"eslint-config-prettier": "^6.7.0",
|
||||
"eslint-plugin-flowtype": "^4.5.2",
|
||||
"eslint-plugin-jsdoc": "^18.1.5",
|
||||
"eslint-plugin-prettier": "^3.1.1",
|
||||
"eslint-plugin-react": "^7.16.0",
|
||||
"exports-loader": "^0.7.0",
|
||||
"file-loader": "^4.2.0",
|
||||
@@ -150,6 +158,7 @@
|
||||
"postcss-import": "^12.0.1",
|
||||
"postcss-loader": "^3.0.0",
|
||||
"postcss-scss": "^2.0.0",
|
||||
"prettier": "^1.19.1",
|
||||
"raw-loader": "^3.1.0",
|
||||
"react-test-renderer": "^16.12.0",
|
||||
"sass-loader": "^8.0.0",
|
||||
|
@@ -2,68 +2,68 @@
|
||||
const path = require('path');
|
||||
const loaderUtils = require('loader-utils');
|
||||
const fileCache = {};
|
||||
const isProduction = process.argv.some((arg) => arg === '-p');
|
||||
const isProduction = process.argv.some(arg => arg === '-p');
|
||||
const rootPath = path.resolve('./src');
|
||||
|
||||
module.exports = ({ webpack: loader }) => ({
|
||||
syntax: 'postcss-scss',
|
||||
plugins: {
|
||||
'postcss-import': {
|
||||
addModulesDirectories: ['./src'],
|
||||
syntax: 'postcss-scss',
|
||||
plugins: {
|
||||
'postcss-import': {
|
||||
addModulesDirectories: ['./src'],
|
||||
|
||||
resolve: ((defaultResolve) => (url, basedir, importOptions) =>
|
||||
defaultResolve(
|
||||
// mainly to remove '~' from request
|
||||
loaderUtils.urlToRequest(url),
|
||||
basedir,
|
||||
importOptions
|
||||
))(require('postcss-import/lib/resolve-id')),
|
||||
resolve: (defaultResolve => (url, basedir, importOptions) =>
|
||||
defaultResolve(
|
||||
// mainly to remove '~' from request
|
||||
loaderUtils.urlToRequest(url),
|
||||
basedir,
|
||||
importOptions,
|
||||
))(require('postcss-import/lib/resolve-id')),
|
||||
|
||||
load: ((defaultLoad) => (filename, importOptions) => {
|
||||
if (/\.font.(js|json)$/.test(filename)) {
|
||||
// separately process calls to font loader
|
||||
// e.g. `@import '~icons.font.json';`
|
||||
if (!fileCache[filename] || !isProduction) {
|
||||
// do not execute loader on the same file twice
|
||||
// this is an overcome for a bug with ExtractTextPlugin, for isProduction === true
|
||||
// when @imported files may be processed multiple times
|
||||
fileCache[filename] = new Promise((resolve, reject) =>
|
||||
loader.loadModule(filename, (err, source) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
load: (defaultLoad => (filename, importOptions) => {
|
||||
if (/\.font.(js|json)$/.test(filename)) {
|
||||
// separately process calls to font loader
|
||||
// e.g. `@import '~icons.font.json';`
|
||||
if (!fileCache[filename] || !isProduction) {
|
||||
// do not execute loader on the same file twice
|
||||
// this is an overcome for a bug with ExtractTextPlugin, for isProduction === true
|
||||
// when @imported files may be processed multiple times
|
||||
fileCache[filename] = new Promise((resolve, reject) =>
|
||||
loader.loadModule(filename, (err, source) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(loader.exec(source, rootPath));
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return fileCache[filename];
|
||||
return;
|
||||
}
|
||||
|
||||
return defaultLoad(filename, importOptions);
|
||||
})(require('postcss-import/lib/load-content'))
|
||||
},
|
||||
// TODO: for some reason cssnano strips out @mixin declarations
|
||||
// cssnano: {
|
||||
// /**
|
||||
// * TODO: cssnano options
|
||||
// */
|
||||
// // autoprefixer: {
|
||||
// // add: true,
|
||||
// // remove: true,
|
||||
// // browsers: ['last 2 versions']
|
||||
// // },
|
||||
// // safe: true,
|
||||
// // // отключаем минификацию цветов, что бы она не ломала такие выражения:
|
||||
// // // composes: black from '~./buttons.scss';
|
||||
// // colormin: false,
|
||||
// // discardComments: {
|
||||
// // removeAll: true
|
||||
// // }
|
||||
// preset: 'default'
|
||||
// }
|
||||
}
|
||||
resolve(loader.exec(source, rootPath));
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return fileCache[filename];
|
||||
}
|
||||
|
||||
return defaultLoad(filename, importOptions);
|
||||
})(require('postcss-import/lib/load-content')),
|
||||
},
|
||||
// TODO: for some reason cssnano strips out @mixin declarations
|
||||
// cssnano: {
|
||||
// /**
|
||||
// * TODO: cssnano options
|
||||
// */
|
||||
// // autoprefixer: {
|
||||
// // add: true,
|
||||
// // remove: true,
|
||||
// // browsers: ['last 2 versions']
|
||||
// // },
|
||||
// // safe: true,
|
||||
// // // отключаем минификацию цветов, что бы она не ломала такие выражения:
|
||||
// // // composes: black from '~./buttons.scss';
|
||||
// // colormin: false,
|
||||
// // discardComments: {
|
||||
// // removeAll: true
|
||||
// // }
|
||||
// preset: 'default'
|
||||
// }
|
||||
},
|
||||
});
|
||||
|
@@ -11,58 +11,58 @@ const webpackConfig = require('../webpack.dll.config.js');
|
||||
const compiler = webpack(webpackConfig);
|
||||
|
||||
Promise.all([
|
||||
stat(`${__dirname}/../yarn.lock`),
|
||||
stat(`${__dirname}/../dll/vendor.json`)
|
||||
stat(`${__dirname}/../yarn.lock`),
|
||||
stat(`${__dirname}/../dll/vendor.json`),
|
||||
])
|
||||
.then((stats) => {
|
||||
const lockFile = new Date(stats[0].mtime);
|
||||
const dll = new Date(stats[1].mtime);
|
||||
.then(stats => {
|
||||
const lockFile = new Date(stats[0].mtime);
|
||||
const dll = new Date(stats[1].mtime);
|
||||
|
||||
if (dll < lockFile) {
|
||||
return Promise.reject({
|
||||
code: 'OUTDATED'
|
||||
});
|
||||
if (dll < lockFile) {
|
||||
return Promise.reject({
|
||||
code: 'OUTDATED',
|
||||
});
|
||||
}
|
||||
|
||||
logResult(chalk.green('Current dlls are up to date!'));
|
||||
})
|
||||
.catch(err => {
|
||||
if (err.code !== 'ENOENT' && err.code !== 'OUTDATED') {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
console.log('Rebuilding dlls...');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
compiler.run((err, stats) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
logResult(chalk.green('Current dlls are up to date!'));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.code !== 'ENOENT' && err.code !== 'OUTDATED') {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
logResult(
|
||||
chalk.green('Dll was successfully build in %s ms'),
|
||||
stats.endTime - stats.startTime,
|
||||
);
|
||||
|
||||
console.log('Rebuilding dlls...');
|
||||
|
||||
return new Promise(((resolve, reject) => {
|
||||
compiler.run((err, stats) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
logResult(
|
||||
chalk.green('Dll was successfully build in %s ms'),
|
||||
stats.endTime - stats.startTime
|
||||
);
|
||||
|
||||
resolve();
|
||||
});
|
||||
}));
|
||||
})
|
||||
.catch((err) => {
|
||||
logResult(chalk.red('Unexpected error checking dll state'), err);
|
||||
process.exit(1);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(err => {
|
||||
logResult(chalk.red('Unexpected error checking dll state'), err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
function logResult() {
|
||||
console.log('\n');
|
||||
console.log.apply(console, arguments);
|
||||
console.log('\n');
|
||||
function logResult(...args) {
|
||||
console.log('\n');
|
||||
console.log(...args);
|
||||
console.log('\n');
|
||||
}
|
||||
|
||||
function stat(path) {
|
||||
return new Promise(((resolve, reject) => {
|
||||
fs.stat(path, (err, stats) => {
|
||||
err ? reject(err) : resolve(stats);
|
||||
});
|
||||
}));
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.stat(path, (err, stats) => {
|
||||
err ? reject(err) : resolve(stats);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
@@ -1,8 +1,8 @@
|
||||
/* eslint-env node */
|
||||
/* eslint-disable no-console */
|
||||
import fs from 'fs';
|
||||
import {sync as globSync} from 'glob';
|
||||
import {sync as mkdirpSync} from 'mkdirp';
|
||||
import { sync as globSync } from 'glob';
|
||||
import { sync as mkdirpSync } from 'mkdirp';
|
||||
import chalk from 'chalk';
|
||||
import prompt from 'prompt';
|
||||
|
||||
@@ -22,25 +22,27 @@ const SUPPORTED_LANGS = [DEFAULT_LOCALE, ...Object.keys(localesMap)];
|
||||
let idToFileMap = {};
|
||||
let duplicateIds = [];
|
||||
const collectedMessages = globSync(MESSAGES_PATTERN)
|
||||
.map((filename) => [filename, JSON.parse(fs.readFileSync(filename, 'utf8'))])
|
||||
.reduce((collection, [file, descriptors]) => {
|
||||
descriptors.forEach(({id, defaultMessage}) => {
|
||||
if (collection.hasOwnProperty(id)) {
|
||||
duplicateIds.push(id);
|
||||
}
|
||||
.map(filename => [filename, JSON.parse(fs.readFileSync(filename, 'utf8'))])
|
||||
.reduce((collection, [file, descriptors]) => {
|
||||
descriptors.forEach(({ id, defaultMessage }) => {
|
||||
if (collection.hasOwnProperty(id)) {
|
||||
duplicateIds.push(id);
|
||||
}
|
||||
|
||||
collection[id] = defaultMessage;
|
||||
idToFileMap[id] = (idToFileMap[id] || []).concat(file);
|
||||
});
|
||||
collection[id] = defaultMessage;
|
||||
idToFileMap[id] = (idToFileMap[id] || []).concat(file);
|
||||
});
|
||||
|
||||
return collection;
|
||||
}, {});
|
||||
return collection;
|
||||
}, {});
|
||||
|
||||
if (duplicateIds.length) {
|
||||
console.log('\nFound duplicated ids:');
|
||||
duplicateIds.forEach((id) => console.log(`${chalk.yellow(id)}:\n - ${idToFileMap[id].join('\n - ')}\n`));
|
||||
console.log(chalk.red('Please correct the errors above to proceed further!'));
|
||||
process.exit();
|
||||
console.log('\nFound duplicated ids:');
|
||||
duplicateIds.forEach(id =>
|
||||
console.log(`${chalk.yellow(id)}:\n - ${idToFileMap[id].join('\n - ')}\n`),
|
||||
);
|
||||
console.log(chalk.red('Please correct the errors above to proceed further!'));
|
||||
process.exit();
|
||||
}
|
||||
|
||||
duplicateIds = null;
|
||||
@@ -54,135 +56,173 @@ let keysToUpdate = [];
|
||||
let keysToAdd = [];
|
||||
let keysToRemove = [];
|
||||
const keysToRename = [];
|
||||
const isNotMarked = (value) => value.slice(0, 2) !== '--';
|
||||
const isNotMarked = value => value.slice(0, 2) !== '--';
|
||||
|
||||
const prevMessages = readJSON(defaultMessagesPath);
|
||||
const prevMessagesMap = Object.entries(prevMessages).reduce((acc, [key, value]) => {
|
||||
const prevMessagesMap = Object.entries(prevMessages).reduce(
|
||||
(acc, [key, value]) => {
|
||||
if (acc[value]) {
|
||||
acc[value].push(key);
|
||||
acc[value].push(key);
|
||||
} else {
|
||||
acc[value] = [key];
|
||||
acc[value] = [key];
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
keysToAdd = Object.keys(collectedMessages).filter((key) => !prevMessages[key]);
|
||||
keysToRemove = Object.keys(prevMessages).filter((key) => !collectedMessages[key]).filter(isNotMarked);
|
||||
keysToUpdate = Object.entries(prevMessages).reduce((acc, [key, message]) =>
|
||||
acc.concat(collectedMessages[key] && collectedMessages[key] !== message ? key : [])
|
||||
, []);
|
||||
},
|
||||
{},
|
||||
);
|
||||
keysToAdd = Object.keys(collectedMessages).filter(key => !prevMessages[key]);
|
||||
keysToRemove = Object.keys(prevMessages)
|
||||
.filter(key => !collectedMessages[key])
|
||||
.filter(isNotMarked);
|
||||
keysToUpdate = Object.entries(prevMessages).reduce(
|
||||
(acc, [key, message]) =>
|
||||
acc.concat(
|
||||
collectedMessages[key] && collectedMessages[key] !== message ? key : [],
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
// detect keys to rename, mutating keysToAdd and keysToRemove
|
||||
[].concat(keysToAdd).forEach((toKey) => {
|
||||
const keys = prevMessagesMap[collectedMessages[toKey]] || [];
|
||||
const fromKey = keys.find((fromKey) => keysToRemove.indexOf(fromKey) > -1);
|
||||
[].concat(keysToAdd).forEach(toKey => {
|
||||
const keys = prevMessagesMap[collectedMessages[toKey]] || [];
|
||||
const fromKey = keys.find(fromKey => keysToRemove.indexOf(fromKey) > -1);
|
||||
|
||||
if (fromKey) {
|
||||
keysToRename.push([fromKey, toKey]);
|
||||
if (fromKey) {
|
||||
keysToRename.push([fromKey, toKey]);
|
||||
|
||||
keysToRemove.splice(keysToRemove.indexOf(fromKey), 1);
|
||||
keysToAdd.splice(keysToAdd.indexOf(toKey), 1);
|
||||
}
|
||||
keysToRemove.splice(keysToRemove.indexOf(fromKey), 1);
|
||||
keysToAdd.splice(keysToAdd.indexOf(toKey), 1);
|
||||
}
|
||||
});
|
||||
|
||||
if (!keysToAdd.length && !keysToRemove.length && !keysToUpdate.length && !keysToRename.length) {
|
||||
console.log(chalk.green('Everything is up to date!'));
|
||||
process.exit();
|
||||
if (
|
||||
!keysToAdd.length &&
|
||||
!keysToRemove.length &&
|
||||
!keysToUpdate.length &&
|
||||
!keysToRename.length
|
||||
) {
|
||||
console.log(chalk.green('Everything is up to date!'));
|
||||
process.exit();
|
||||
}
|
||||
|
||||
console.log(chalk.magenta(`The diff relative to default locale (${DEFAULT_LOCALE}) is:`));
|
||||
console.log(
|
||||
chalk.magenta(`The diff relative to default locale (${DEFAULT_LOCALE}) is:`),
|
||||
);
|
||||
|
||||
if (keysToRemove.length) {
|
||||
console.log('The following keys will be removed:');
|
||||
console.log([chalk.red('\n - '), keysToRemove.join(chalk.red('\n - ')), '\n'].join(''));
|
||||
console.log('The following keys will be removed:');
|
||||
console.log(
|
||||
[chalk.red('\n - '), keysToRemove.join(chalk.red('\n - ')), '\n'].join(''),
|
||||
);
|
||||
}
|
||||
|
||||
if (keysToAdd.length) {
|
||||
console.log('The following keys will be added:');
|
||||
console.log([chalk.green('\n + '), keysToAdd.join(chalk.green('\n + ')), '\n'].join(''));
|
||||
console.log('The following keys will be added:');
|
||||
console.log(
|
||||
[chalk.green('\n + '), keysToAdd.join(chalk.green('\n + ')), '\n'].join(''),
|
||||
);
|
||||
}
|
||||
|
||||
if (keysToUpdate.length) {
|
||||
console.log('The following keys will be updated:');
|
||||
console.log([chalk.yellow('\n @ '), keysToUpdate.join(chalk.yellow('\n @ ')), '\n'].join(''));
|
||||
console.log('The following keys will be updated:');
|
||||
console.log(
|
||||
[
|
||||
chalk.yellow('\n @ '),
|
||||
keysToUpdate.join(chalk.yellow('\n @ ')),
|
||||
'\n',
|
||||
].join(''),
|
||||
);
|
||||
}
|
||||
|
||||
if (keysToRename.length) {
|
||||
console.log('The following keys will be renamed:\n');
|
||||
console.log(keysToRename.reduce((str, pair) =>
|
||||
[str, pair[0], chalk.yellow(' -> '), pair[1], '\n'].join('')
|
||||
, ''));
|
||||
console.log('The following keys will be renamed:\n');
|
||||
console.log(
|
||||
keysToRename.reduce(
|
||||
(str, pair) =>
|
||||
[str, pair[0], chalk.yellow(' -> '), pair[1], '\n'].join(''),
|
||||
'',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
prompt.start();
|
||||
prompt.get({
|
||||
prompt.get(
|
||||
{
|
||||
properties: {
|
||||
apply: {
|
||||
description: 'Apply changes? [Y/n]',
|
||||
pattern: /^y|n$/i,
|
||||
message: 'Please enter "y" or "n"',
|
||||
default: 'y',
|
||||
before: (value) => value.toLowerCase() === 'y'
|
||||
}
|
||||
}
|
||||
}, (err, resp) => {
|
||||
apply: {
|
||||
description: 'Apply changes? [Y/n]',
|
||||
pattern: /^y|n$/i,
|
||||
message: 'Please enter "y" or "n"',
|
||||
default: 'y',
|
||||
before: value => value.toLowerCase() === 'y',
|
||||
},
|
||||
},
|
||||
},
|
||||
(err, resp) => {
|
||||
console.log('\n');
|
||||
|
||||
if (err || !resp.apply) {
|
||||
return console.log(chalk.red('Aborted'));
|
||||
return console.log(chalk.red('Aborted'));
|
||||
}
|
||||
|
||||
buildLocales();
|
||||
|
||||
console.log(chalk.green('All locales was successfuly built'));
|
||||
});
|
||||
|
||||
},
|
||||
);
|
||||
|
||||
function buildLocales() {
|
||||
mkdirpSync(LANG_DIR);
|
||||
mkdirpSync(LANG_DIR);
|
||||
|
||||
SUPPORTED_LANGS.map((lang) => {
|
||||
const destPath = `${LANG_DIR}/${lang}.json`;
|
||||
const newMessages = readJSON(destPath);
|
||||
SUPPORTED_LANGS.map(lang => {
|
||||
const destPath = `${LANG_DIR}/${lang}.json`;
|
||||
const newMessages = readJSON(destPath);
|
||||
|
||||
keysToRename.forEach(([fromKey, toKey]) => {
|
||||
newMessages[toKey] = newMessages[fromKey];
|
||||
delete newMessages[fromKey];
|
||||
});
|
||||
keysToRemove.forEach((key) => {
|
||||
delete newMessages[key];
|
||||
});
|
||||
keysToUpdate.forEach((key) => {
|
||||
newMessages[`--${key}`] = newMessages[key];
|
||||
newMessages[key] = collectedMessages[key];
|
||||
});
|
||||
keysToAdd.forEach((key) => {
|
||||
newMessages[key] = collectedMessages[key];
|
||||
});
|
||||
|
||||
const sortedKeys = Object.keys(newMessages).sort((key1, key2) => {
|
||||
key1 = key1.replace(/^\-+/, '');
|
||||
key2 = key2.replace(/^\-+/, '');
|
||||
|
||||
return key1 < key2 || !isNotMarked(key1) ? -1 : 1;
|
||||
});
|
||||
|
||||
const sortedNewMessages = sortedKeys.reduce((acc, key) => {
|
||||
acc[key] = newMessages[key];
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
fs.writeFileSync(destPath, JSON.stringify(sortedNewMessages, null, 4) + '\n');
|
||||
keysToRename.forEach(([fromKey, toKey]) => {
|
||||
newMessages[toKey] = newMessages[fromKey];
|
||||
delete newMessages[fromKey];
|
||||
});
|
||||
keysToRemove.forEach(key => {
|
||||
delete newMessages[key];
|
||||
});
|
||||
keysToUpdate.forEach(key => {
|
||||
newMessages[`--${key}`] = newMessages[key];
|
||||
newMessages[key] = collectedMessages[key];
|
||||
});
|
||||
keysToAdd.forEach(key => {
|
||||
newMessages[key] = collectedMessages[key];
|
||||
});
|
||||
|
||||
const sortedKeys = Object.keys(newMessages).sort((key1, key2) => {
|
||||
key1 = key1.replace(/^-+/, '');
|
||||
key2 = key2.replace(/^-+/, '');
|
||||
|
||||
return key1 < key2 || !isNotMarked(key1) ? -1 : 1;
|
||||
});
|
||||
|
||||
const sortedNewMessages = sortedKeys.reduce((acc, key) => {
|
||||
acc[key] = newMessages[key];
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
fs.writeFileSync(
|
||||
destPath,
|
||||
`${JSON.stringify(sortedNewMessages, null, 4)}\n`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function readJSON(destPath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(destPath, 'utf8'));
|
||||
} catch (err) {
|
||||
console.log(chalk.yellow(`Can not read ${destPath}. The new file will be created.`), `(${err.message})`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(destPath, 'utf8'));
|
||||
} catch (err) {
|
||||
console.log(
|
||||
chalk.yellow(`Can not read ${destPath}. The new file will be created.`),
|
||||
`(${err.message})`,
|
||||
);
|
||||
}
|
||||
|
||||
return {};
|
||||
return {};
|
||||
}
|
||||
|
@@ -13,8 +13,8 @@ import prompt from 'prompt';
|
||||
import config from '../config';
|
||||
|
||||
if (!config.crowdinApiKey) {
|
||||
console.error(ch.red`crowdinApiKey is required`);
|
||||
process.exit(126);
|
||||
console.error(ch.red`crowdinApiKey is required`);
|
||||
process.exit(126);
|
||||
}
|
||||
|
||||
const PROJECT_ID = 'elyby';
|
||||
@@ -37,41 +37,41 @@ const RELEASED_LOCALES = ['be', 'fr', 'id', 'pt', 'ru', 'uk', 'vi', 'zh'];
|
||||
* Array of Crowdin locales to our internal locales representation
|
||||
*/
|
||||
const LOCALES_MAP = {
|
||||
'pt-BR': 'pt',
|
||||
'zh-CN': 'zh',
|
||||
'pt-BR': 'pt',
|
||||
'zh-CN': 'zh',
|
||||
};
|
||||
|
||||
/**
|
||||
* This array allows us to customise native languages names, because ISO-639-1 sometimes is strange
|
||||
*/
|
||||
const NATIVE_NAMES_MAP = {
|
||||
be: 'Беларуская',
|
||||
id: 'Bahasa Indonesia',
|
||||
lt: 'Lietuvių',
|
||||
pl: 'Polski',
|
||||
pt: 'Português do Brasil',
|
||||
sr: 'Српски',
|
||||
ro: 'Română',
|
||||
zh: '简体中文',
|
||||
be: 'Беларуская',
|
||||
id: 'Bahasa Indonesia',
|
||||
lt: 'Lietuvių',
|
||||
pl: 'Polski',
|
||||
pt: 'Português do Brasil',
|
||||
sr: 'Српски',
|
||||
ro: 'Română',
|
||||
zh: '简体中文',
|
||||
};
|
||||
|
||||
/**
|
||||
* This arrays allows us to override Crowdin English languages names
|
||||
*/
|
||||
const ENGLISH_NAMES_MAP = {
|
||||
pt: 'Portuguese, Brazilian',
|
||||
sr: 'Serbian',
|
||||
zh: 'Simplified Chinese',
|
||||
pt: 'Portuguese, Brazilian',
|
||||
sr: 'Serbian',
|
||||
zh: 'Simplified Chinese',
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts Crowdin's language code to our internal value
|
||||
*
|
||||
* @param {string} code
|
||||
* @return {string}
|
||||
* @returns {string}
|
||||
*/
|
||||
function toInternalLocale(code: string): string {
|
||||
return LOCALES_MAP[code] || code;
|
||||
return LOCALES_MAP[code] || code;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,256 +79,319 @@ function toInternalLocale(code: string): string {
|
||||
* хранятся в самом приложении
|
||||
*
|
||||
* @param {object} translates
|
||||
* @return {string}
|
||||
* @returns {string}
|
||||
*/
|
||||
function serializeToFormattedJson(translates: Object): string {
|
||||
return JSON.stringify(sortByKeys(translates), null, 4) + '\n'; // eslint-disable-line prefer-template
|
||||
return JSON.stringify(sortByKeys(translates), null, 4) + '\n'; // eslint-disable-line prefer-template
|
||||
}
|
||||
|
||||
/**
|
||||
* http://stackoverflow.com/a/29622653/5184751
|
||||
*
|
||||
* @param {object} object
|
||||
* @return {object}
|
||||
* @returns {object}
|
||||
*/
|
||||
function sortByKeys(object: Object): Object {
|
||||
return Object.keys(object).sort().reduce((result, key) => {
|
||||
result[key] = object[key];
|
||||
return result;
|
||||
return Object.keys(object)
|
||||
.sort()
|
||||
.reduce((result, key) => {
|
||||
result[key] = object[key];
|
||||
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
interface ProjectInfoFile {
|
||||
node_type: 'file';
|
||||
id: number;
|
||||
name: string;
|
||||
created: string;
|
||||
last_updated: string;
|
||||
last_accessed: string;
|
||||
last_revision: string;
|
||||
node_type: 'file';
|
||||
id: number;
|
||||
name: string;
|
||||
created: string;
|
||||
last_updated: string;
|
||||
last_accessed: string;
|
||||
last_revision: string;
|
||||
}
|
||||
|
||||
interface ProjectInfoDirectory {
|
||||
node_type: 'directory';
|
||||
id: number;
|
||||
name: string;
|
||||
files: Array<ProjectInfoFile | ProjectInfoDirectory>;
|
||||
node_type: 'directory';
|
||||
id: number;
|
||||
name: string;
|
||||
files: Array<ProjectInfoFile | ProjectInfoDirectory>;
|
||||
}
|
||||
|
||||
interface ProjectInfoResponse {
|
||||
details: {
|
||||
source_language: {
|
||||
name: string;
|
||||
code: string;
|
||||
};
|
||||
name: string;
|
||||
identifier: string;
|
||||
created: string;
|
||||
description: string;
|
||||
join_policy: string;
|
||||
last_build: string | null;
|
||||
last_activity: string;
|
||||
participants_count: string; // it's number, but string in the response
|
||||
logo_url: string | null;
|
||||
total_strings_count: string; // it's number, but string in the response
|
||||
total_words_count: string; // it's number, but string in the response
|
||||
duplicate_strings_count: number;
|
||||
duplicate_words_count: number;
|
||||
invite_url: {
|
||||
translator: string;
|
||||
proofreader: string;
|
||||
};
|
||||
};
|
||||
languages: Array<{
|
||||
name: string; // English language name
|
||||
code: string;
|
||||
can_translate: 0 | 1;
|
||||
can_approve: 0 | 1;
|
||||
}>;
|
||||
files: Array<ProjectInfoFile | ProjectInfoDirectory>;
|
||||
details: {
|
||||
source_language: {
|
||||
name: string,
|
||||
code: string,
|
||||
},
|
||||
name: string,
|
||||
identifier: string,
|
||||
created: string,
|
||||
description: string,
|
||||
join_policy: string,
|
||||
last_build: string | null,
|
||||
last_activity: string,
|
||||
participants_count: string, // it's number, but string in the response
|
||||
logo_url: string | null,
|
||||
total_strings_count: string, // it's number, but string in the response
|
||||
total_words_count: string, // it's number, but string in the response
|
||||
duplicate_strings_count: number,
|
||||
duplicate_words_count: number,
|
||||
invite_url: {
|
||||
translator: string,
|
||||
proofreader: string,
|
||||
},
|
||||
};
|
||||
languages: Array<{
|
||||
name: string, // English language name
|
||||
code: string,
|
||||
can_translate: 0 | 1,
|
||||
can_approve: 0 | 1,
|
||||
}>;
|
||||
files: Array<ProjectInfoFile | ProjectInfoDirectory>;
|
||||
}
|
||||
|
||||
async function pullLocales() {
|
||||
const { languages }: ProjectInfoResponse = await crowdin.projectInfo(PROJECT_ID);
|
||||
return languages;
|
||||
const { languages }: ProjectInfoResponse = await crowdin.projectInfo(
|
||||
PROJECT_ID,
|
||||
);
|
||||
|
||||
return languages;
|
||||
}
|
||||
|
||||
interface LanguageStatusNode {
|
||||
node_type: 'directory' | 'file';
|
||||
id: number;
|
||||
name: string;
|
||||
phrases: number;
|
||||
translated: number;
|
||||
approved: number;
|
||||
words: number;
|
||||
words_translated: number;
|
||||
words_approved: number;
|
||||
files: Array<LanguageStatusNode>;
|
||||
node_type: 'directory' | 'file';
|
||||
id: number;
|
||||
name: string;
|
||||
phrases: number;
|
||||
translated: number;
|
||||
approved: number;
|
||||
words: number;
|
||||
words_translated: number;
|
||||
words_approved: number;
|
||||
files: Array<LanguageStatusNode>;
|
||||
}
|
||||
|
||||
function findFile(root: Array<LanguageStatusNode>, path: string): LanguageStatusNode | null {
|
||||
const [nodeToSearch, ...rest] = path.split('/');
|
||||
for (const node of root) {
|
||||
if (node.name !== nodeToSearch) {
|
||||
continue;
|
||||
}
|
||||
function findFile(
|
||||
root: Array<LanguageStatusNode>,
|
||||
path: string,
|
||||
): LanguageStatusNode | null {
|
||||
const [nodeToSearch, ...rest] = path.split('/');
|
||||
|
||||
if (rest.length === 0) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return findFile(node.files, rest.join('/'));
|
||||
for (const node of root) {
|
||||
if (node.name !== nodeToSearch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return null;
|
||||
if (rest.length === 0) {
|
||||
return node;
|
||||
}
|
||||
|
||||
return findFile(node.files, rest.join('/'));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
interface IndexFileEntry {
|
||||
code: string;
|
||||
name: string;
|
||||
englishName: string;
|
||||
progress: number;
|
||||
isReleased: bool;
|
||||
code: string;
|
||||
name: string;
|
||||
englishName: string;
|
||||
progress: number;
|
||||
isReleased: boolean;
|
||||
}
|
||||
|
||||
async function pull() {
|
||||
console.log('Pulling locales list...');
|
||||
const locales = await pullLocales();
|
||||
const checkingProgressBar = progressBar.newBar('| Pulling locales info :bar :percent | :current/:total', {
|
||||
total: locales.length,
|
||||
incomplete: '\u2591',
|
||||
complete: '\u2588',
|
||||
width: locales.length,
|
||||
});
|
||||
// Add prefix 'c' to current and total to prevent filling thees placeholders with real values
|
||||
const downloadingProgressBar = progressBar.newBar('| Downloading translates :bar :percent | :cCurrent/:cTotal', {
|
||||
total: 100,
|
||||
incomplete: '\u2591',
|
||||
complete: '\u2588',
|
||||
width: locales.length,
|
||||
});
|
||||
let downloadingTotal = 0;
|
||||
let downloadingReady = 0;
|
||||
const results = await Promise.all(locales.map(async (locale) => {
|
||||
const { files }: { files: Array<LanguageStatusNode> } = await crowdin.languageStatus(PROJECT_ID, locale.code);
|
||||
checkingProgressBar.tick();
|
||||
const fileInfo = findFile(files, CROWDIN_FILE_PATH);
|
||||
if (fileInfo === null) {
|
||||
throw new Error('Unable to find translation file. Please check the CROWDIN_FILE_PATH param.');
|
||||
}
|
||||
console.log('Pulling locales list...');
|
||||
const locales = await pullLocales();
|
||||
const checkingProgressBar = progressBar.newBar(
|
||||
'| Pulling locales info :bar :percent | :current/:total',
|
||||
{
|
||||
total: locales.length,
|
||||
incomplete: '\u2591',
|
||||
complete: '\u2588',
|
||||
width: locales.length,
|
||||
},
|
||||
);
|
||||
// Add prefix 'c' to current and total to prevent filling thees placeholders with real values
|
||||
const downloadingProgressBar = progressBar.newBar(
|
||||
'| Downloading translates :bar :percent | :cCurrent/:cTotal',
|
||||
{
|
||||
total: 100,
|
||||
incomplete: '\u2591',
|
||||
complete: '\u2588',
|
||||
width: locales.length,
|
||||
},
|
||||
);
|
||||
let downloadingTotal = 0;
|
||||
let downloadingReady = 0;
|
||||
const results = await Promise.all(
|
||||
locales.map(async locale => {
|
||||
const {
|
||||
files,
|
||||
}: { files: Array<LanguageStatusNode> } = await crowdin.languageStatus(
|
||||
PROJECT_ID,
|
||||
locale.code,
|
||||
);
|
||||
checkingProgressBar.tick();
|
||||
const fileInfo = findFile(files, CROWDIN_FILE_PATH);
|
||||
|
||||
const progress = fileInfo.words_approved / fileInfo.words * 100;
|
||||
if (!RELEASED_LOCALES.includes(toInternalLocale(locale.code)) && progress < MIN_RELEASE_PROGRESS) {
|
||||
return null;
|
||||
}
|
||||
if (fileInfo === null) {
|
||||
throw new Error(
|
||||
'Unable to find translation file. Please check the CROWDIN_FILE_PATH param.',
|
||||
);
|
||||
}
|
||||
|
||||
downloadingProgressBar.update(downloadingReady / ++downloadingTotal, {
|
||||
cCurrent: downloadingReady,
|
||||
cTotal: downloadingTotal,
|
||||
});
|
||||
const progress = (fileInfo.words_approved / fileInfo.words) * 100;
|
||||
|
||||
const translatesFilePath = await crowdin.exportFile(PROJECT_ID, CROWDIN_FILE_PATH, locale.code);
|
||||
if (
|
||||
!RELEASED_LOCALES.includes(toInternalLocale(locale.code)) &&
|
||||
progress < MIN_RELEASE_PROGRESS
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
downloadingProgressBar.update(++downloadingReady / downloadingTotal, {
|
||||
cCurrent: downloadingReady,
|
||||
cTotal: downloadingTotal,
|
||||
});
|
||||
downloadingProgressBar.update(downloadingReady / ++downloadingTotal, {
|
||||
cCurrent: downloadingReady,
|
||||
cTotal: downloadingTotal,
|
||||
});
|
||||
|
||||
return {
|
||||
locale,
|
||||
const translatesFilePath = await crowdin.exportFile(
|
||||
PROJECT_ID,
|
||||
CROWDIN_FILE_PATH,
|
||||
locale.code,
|
||||
);
|
||||
|
||||
downloadingProgressBar.update(++downloadingReady / downloadingTotal, {
|
||||
cCurrent: downloadingReady,
|
||||
cTotal: downloadingTotal,
|
||||
});
|
||||
|
||||
return {
|
||||
locale,
|
||||
progress,
|
||||
translatesFilePath,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
console.log('Locales are downloaded. Writing them to file system.');
|
||||
|
||||
const indexFileEntries: { [string]: IndexFileEntry } = {
|
||||
en: {
|
||||
code: 'en',
|
||||
name: 'English',
|
||||
englishName: 'English',
|
||||
progress: 100,
|
||||
isReleased: true,
|
||||
},
|
||||
};
|
||||
// $FlowFixMe
|
||||
await Promise.all(
|
||||
results.map(
|
||||
result =>
|
||||
new Promise((resolve, reject) => {
|
||||
if (result === null) {
|
||||
resolve();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
locale: { code, name },
|
||||
progress,
|
||||
translatesFilePath,
|
||||
};
|
||||
}));
|
||||
} = result;
|
||||
const ourCode = toInternalLocale(code);
|
||||
|
||||
console.log('Locales are downloaded. Writing them to file system.');
|
||||
|
||||
const indexFileEntries: { [string]: IndexFileEntry } = {
|
||||
en: {
|
||||
code: 'en',
|
||||
name: 'English',
|
||||
englishName: 'English',
|
||||
progress: 100,
|
||||
isReleased: true,
|
||||
},
|
||||
};
|
||||
// $FlowFixMe
|
||||
await Promise.all(results.map((result) => new Promise((resolve, reject) => {
|
||||
if (result === null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const { locale: { code, name }, progress, translatesFilePath } = result;
|
||||
const ourCode = toInternalLocale(code);
|
||||
|
||||
indexFileEntries[ourCode] = {
|
||||
indexFileEntries[ourCode] = {
|
||||
code: ourCode,
|
||||
name: NATIVE_NAMES_MAP[ourCode] || iso639.getNativeName(ourCode),
|
||||
englishName: ENGLISH_NAMES_MAP[ourCode] || name,
|
||||
progress: parseFloat(progress.toFixed(1)),
|
||||
isReleased: RELEASED_LOCALES.includes(ourCode),
|
||||
};
|
||||
};
|
||||
|
||||
fs.copyFile(translatesFilePath, path.join(LANG_DIR, `${ourCode}.json`), 0, (err) => {
|
||||
err ? reject(err) : resolve();
|
||||
});
|
||||
})));
|
||||
fs.copyFile(
|
||||
translatesFilePath,
|
||||
path.join(LANG_DIR, `${ourCode}.json`),
|
||||
0,
|
||||
err => {
|
||||
err ? reject(err) : resolve();
|
||||
},
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
console.log('Writing an index file.');
|
||||
console.log('Writing an index file.');
|
||||
|
||||
fs.writeFileSync(path.join(LANG_DIR, INDEX_FILE_NAME), serializeToFormattedJson(indexFileEntries));
|
||||
fs.writeFileSync(
|
||||
path.join(LANG_DIR, INDEX_FILE_NAME),
|
||||
serializeToFormattedJson(indexFileEntries),
|
||||
);
|
||||
|
||||
console.log(ch.green('The index file was successfully written'));
|
||||
console.log(ch.green('The index file was successfully written'));
|
||||
}
|
||||
|
||||
function push() {
|
||||
return new Promise((resolve, reject) => {
|
||||
prompt.start();
|
||||
prompt.get({
|
||||
properties: {
|
||||
disapprove: {
|
||||
description: 'Disapprove changed lines? [Y/n]',
|
||||
pattern: /^y|n$/i,
|
||||
message: 'Please enter "y" or "n"',
|
||||
default: 'y',
|
||||
before: (value) => value.toLowerCase() === 'y',
|
||||
},
|
||||
},
|
||||
}, async (err, { disapprove }) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
prompt.start();
|
||||
prompt.get(
|
||||
{
|
||||
properties: {
|
||||
disapprove: {
|
||||
description: 'Disapprove changed lines? [Y/n]',
|
||||
pattern: /^y|n$/i,
|
||||
message: 'Please enter "y" or "n"',
|
||||
default: 'y',
|
||||
before: value => value.toLowerCase() === 'y',
|
||||
},
|
||||
},
|
||||
},
|
||||
async (err, { disapprove }) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
|
||||
console.log(`Publishing ${ch.bold(SOURCE_LANG)} translates file...`);
|
||||
return;
|
||||
}
|
||||
|
||||
await crowdin.updateFile(PROJECT_ID, {
|
||||
[CROWDIN_FILE_PATH]: path.join(LANG_DIR, `${SOURCE_LANG}.json`),
|
||||
}, {
|
||||
// eslint-disable-next-line camelcase
|
||||
update_option: disapprove ? 'update_as_unapproved' : 'update_without_changes',
|
||||
});
|
||||
console.log(`Publishing ${ch.bold(SOURCE_LANG)} translates file...`);
|
||||
|
||||
console.log(ch.green('Success'));
|
||||
await crowdin.updateFile(
|
||||
PROJECT_ID,
|
||||
{
|
||||
[CROWDIN_FILE_PATH]: path.join(LANG_DIR, `${SOURCE_LANG}.json`),
|
||||
},
|
||||
{
|
||||
// eslint-disable-next-line camelcase
|
||||
update_option: disapprove
|
||||
? 'update_as_unapproved'
|
||||
: 'update_without_changes',
|
||||
},
|
||||
);
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
console.log(ch.green('Success'));
|
||||
|
||||
resolve();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const action = process.argv[2];
|
||||
switch (action) {
|
||||
case 'pull':
|
||||
pull();
|
||||
break;
|
||||
case 'push':
|
||||
push();
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown action ${action}`);
|
||||
}
|
||||
const action = process.argv[2];
|
||||
|
||||
switch (action) {
|
||||
case 'pull':
|
||||
pull();
|
||||
break;
|
||||
case 'push':
|
||||
push();
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown action ${action}`);
|
||||
}
|
||||
} catch (exception) {
|
||||
console.error(exception);
|
||||
console.error(exception);
|
||||
}
|
||||
|
30
src/App.js
30
src/App.js
@@ -8,21 +8,21 @@ import AuthFlowRoute from 'containers/AuthFlowRoute';
|
||||
import RootPage from 'pages/root/RootPage';
|
||||
import SuccessOauthPage from 'pages/auth/SuccessOauthPage';
|
||||
|
||||
const App = ({store, browserHistory}) => (
|
||||
<ReduxProvider store={store}>
|
||||
<IntlProvider>
|
||||
<Router history={browserHistory}>
|
||||
<Switch>
|
||||
<Route path="/oauth2/code/success" component={SuccessOauthPage} />
|
||||
<AuthFlowRoute
|
||||
path="/oauth2/:version(v\d+)/:clientId?"
|
||||
component={() => null}
|
||||
/>
|
||||
<Route path="/" component={RootPage} />
|
||||
</Switch>
|
||||
</Router>
|
||||
</IntlProvider>
|
||||
</ReduxProvider>
|
||||
const App = ({ store, browserHistory }) => (
|
||||
<ReduxProvider store={store}>
|
||||
<IntlProvider>
|
||||
<Router history={browserHistory}>
|
||||
<Switch>
|
||||
<Route path="/oauth2/code/success" component={SuccessOauthPage} />
|
||||
<AuthFlowRoute
|
||||
path="/oauth2/:version(v\d+)/:clientId?"
|
||||
component={() => null}
|
||||
/>
|
||||
<Route path="/" component={RootPage} />
|
||||
</Switch>
|
||||
</Router>
|
||||
</IntlProvider>
|
||||
</ReduxProvider>
|
||||
);
|
||||
|
||||
export default hot(App);
|
||||
|
@@ -26,46 +26,49 @@ import { omit, debounce } from 'functions';
|
||||
type ChildState = mixed;
|
||||
|
||||
export default class MeasureHeight extends PureComponent<{
|
||||
shouldMeasure: (prevState: ChildState, newState: ChildState) => bool,
|
||||
onMeasure: (height: number) => void,
|
||||
state: ChildState
|
||||
shouldMeasure: (prevState: ChildState, newState: ChildState) => boolean,
|
||||
onMeasure: (height: number) => void,
|
||||
state: ChildState,
|
||||
}> {
|
||||
static defaultProps = {
|
||||
shouldMeasure: (prevState: ChildState, newState: ChildState) => prevState !== newState,
|
||||
onMeasure: (height: number) => {} // eslint-disable-line
|
||||
};
|
||||
static defaultProps = {
|
||||
shouldMeasure: (prevState: ChildState, newState: ChildState) =>
|
||||
prevState !== newState,
|
||||
onMeasure: (height: number) => {}, // eslint-disable-line
|
||||
};
|
||||
|
||||
el: ?HTMLDivElement;
|
||||
el: ?HTMLDivElement;
|
||||
|
||||
componentDidMount() {
|
||||
// we want to measure height immediately on first mount to avoid ui laggs
|
||||
this.measure();
|
||||
window.addEventListener('resize', this.enqueueMeasurement);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: typeof MeasureHeight.prototype.props) {
|
||||
if (this.props.shouldMeasure(prevProps.state, this.props.state)) {
|
||||
this.enqueueMeasurement();
|
||||
}
|
||||
}
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.enqueueMeasurement);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.enqueueMeasurement);
|
||||
}
|
||||
render() {
|
||||
const props: Object = omit(this.props, [
|
||||
'shouldMeasure',
|
||||
'onMeasure',
|
||||
'state',
|
||||
]);
|
||||
|
||||
render() {
|
||||
const props: Object = omit(this.props, [
|
||||
'shouldMeasure',
|
||||
'onMeasure',
|
||||
'state'
|
||||
]);
|
||||
return <div {...props} ref={(el: HTMLDivElement) => (this.el = el)} />;
|
||||
}
|
||||
|
||||
return <div {...props} ref={(el: HTMLDivElement) => this.el = el} />;
|
||||
}
|
||||
measure = () => {
|
||||
requestAnimationFrame(() => {
|
||||
this.el && this.props.onMeasure(this.el.offsetHeight);
|
||||
});
|
||||
};
|
||||
|
||||
measure = () => {
|
||||
requestAnimationFrame(() => {this.el && this.props.onMeasure(this.el.offsetHeight);});
|
||||
};
|
||||
|
||||
enqueueMeasurement = debounce(this.measure);
|
||||
enqueueMeasurement = debounce(this.measure);
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"addAccount": "Add account",
|
||||
"goToEly": "Go to Ely.by profile",
|
||||
"logout": "Log out"
|
||||
"addAccount": "Add account",
|
||||
"goToEly": "Go to Ely.by profile",
|
||||
"logout": "Log out"
|
||||
}
|
||||
|
@@ -14,149 +14,178 @@ import styles from './accountSwitcher.scss';
|
||||
import messages from './AccountSwitcher.intl.json';
|
||||
|
||||
export class AccountSwitcher extends Component {
|
||||
static displayName = 'AccountSwitcher';
|
||||
static displayName = 'AccountSwitcher';
|
||||
|
||||
static propTypes = {
|
||||
switchAccount: PropTypes.func.isRequired,
|
||||
removeAccount: PropTypes.func.isRequired,
|
||||
onAfterAction: PropTypes.func, // called after each action performed
|
||||
onSwitch: PropTypes.func, // called after switching an account. The active account will be passed as arg
|
||||
accounts: PropTypes.object, // eslint-disable-line
|
||||
skin: PropTypes.oneOf(skins),
|
||||
highlightActiveAccount: PropTypes.bool, // whether active account should be expanded and shown on the top
|
||||
allowLogout: PropTypes.bool, // whether to show logout icon near each account
|
||||
allowAdd: PropTypes.bool // whether to show add account button
|
||||
};
|
||||
static propTypes = {
|
||||
switchAccount: PropTypes.func.isRequired,
|
||||
removeAccount: PropTypes.func.isRequired,
|
||||
onAfterAction: PropTypes.func, // called after each action performed
|
||||
onSwitch: PropTypes.func, // called after switching an account. The active account will be passed as arg
|
||||
accounts: PropTypes.object, // eslint-disable-line
|
||||
skin: PropTypes.oneOf(skins),
|
||||
highlightActiveAccount: PropTypes.bool, // whether active account should be expanded and shown on the top
|
||||
allowLogout: PropTypes.bool, // whether to show logout icon near each account
|
||||
allowAdd: PropTypes.bool, // whether to show add account button
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
skin: SKIN_DARK,
|
||||
highlightActiveAccount: true,
|
||||
allowLogout: true,
|
||||
allowAdd: true,
|
||||
onAfterAction() {},
|
||||
onSwitch() {}
|
||||
};
|
||||
static defaultProps = {
|
||||
skin: SKIN_DARK,
|
||||
highlightActiveAccount: true,
|
||||
allowLogout: true,
|
||||
allowAdd: true,
|
||||
onAfterAction() {},
|
||||
onSwitch() {},
|
||||
};
|
||||
|
||||
render() {
|
||||
const { accounts, skin, allowAdd, allowLogout, highlightActiveAccount } = this.props;
|
||||
const activeAccount = getActiveAccount({ accounts });
|
||||
render() {
|
||||
const {
|
||||
accounts,
|
||||
skin,
|
||||
allowAdd,
|
||||
allowLogout,
|
||||
highlightActiveAccount,
|
||||
} = this.props;
|
||||
const activeAccount = getActiveAccount({ accounts });
|
||||
|
||||
let {available} = accounts;
|
||||
let { available } = accounts;
|
||||
|
||||
if (highlightActiveAccount) {
|
||||
available = available.filter((account) => account.id !== activeAccount.id);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(
|
||||
styles.accountSwitcher,
|
||||
styles[`${skin}AccountSwitcher`],
|
||||
)}>
|
||||
{highlightActiveAccount ? (
|
||||
<div className={styles.item}>
|
||||
<div className={classNames(
|
||||
styles.accountIcon,
|
||||
styles.activeAccountIcon,
|
||||
styles.accountIcon1
|
||||
)} />
|
||||
<div className={styles.activeAccountInfo}>
|
||||
<div className={styles.activeAccountUsername}>
|
||||
{activeAccount.username}
|
||||
</div>
|
||||
<div className={classNames(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} onClick={this.onRemove(activeAccount)} href="#">
|
||||
<Message {...messages.logout} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{available.map((account, index) => (
|
||||
<div className={classNames(styles.item, styles.accountSwitchItem)}
|
||||
key={account.id}
|
||||
onClick={this.onSwitch(account)}
|
||||
>
|
||||
<div className={classNames(
|
||||
styles.accountIcon,
|
||||
styles[`accountIcon${index % 7 + (highlightActiveAccount ? 2 : 1)}`]
|
||||
)} />
|
||||
|
||||
{allowLogout ? (
|
||||
<div className={styles.logoutIcon} 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}
|
||||
block
|
||||
small
|
||||
className={styles.addAccount}
|
||||
label={
|
||||
<Message {...messages.addAccount}>
|
||||
{(message) => (
|
||||
<span>
|
||||
<div className={styles.addIcon} />
|
||||
{message}
|
||||
</span>
|
||||
)}
|
||||
</Message>
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
if (highlightActiveAccount) {
|
||||
available = available.filter(account => account.id !== activeAccount.id);
|
||||
}
|
||||
|
||||
onSwitch = (account) => (event) => {
|
||||
event.preventDefault();
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.accountSwitcher,
|
||||
styles[`${skin}AccountSwitcher`],
|
||||
)}
|
||||
>
|
||||
{highlightActiveAccount ? (
|
||||
<div className={styles.item}>
|
||||
<div
|
||||
className={classNames(
|
||||
styles.accountIcon,
|
||||
styles.activeAccountIcon,
|
||||
styles.accountIcon1,
|
||||
)}
|
||||
/>
|
||||
<div className={styles.activeAccountInfo}>
|
||||
<div className={styles.activeAccountUsername}>
|
||||
{activeAccount.username}
|
||||
</div>
|
||||
<div
|
||||
className={classNames(
|
||||
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}
|
||||
onClick={this.onRemove(activeAccount)}
|
||||
href="#"
|
||||
>
|
||||
<Message {...messages.logout} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{available.map((account, index) => (
|
||||
<div
|
||||
className={classNames(styles.item, styles.accountSwitchItem)}
|
||||
key={account.id}
|
||||
onClick={this.onSwitch(account)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
styles.accountIcon,
|
||||
styles[
|
||||
`accountIcon${(index % 7) + (highlightActiveAccount ? 2 : 1)}`
|
||||
],
|
||||
)}
|
||||
/>
|
||||
|
||||
loader.show();
|
||||
{allowLogout ? (
|
||||
<div
|
||||
className={styles.logoutIcon}
|
||||
onClick={this.onRemove(account)}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.nextIcon} />
|
||||
)}
|
||||
|
||||
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());
|
||||
};
|
||||
<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}
|
||||
block
|
||||
small
|
||||
className={styles.addAccount}
|
||||
label={
|
||||
<Message {...messages.addAccount}>
|
||||
{message => (
|
||||
<span>
|
||||
<div className={styles.addIcon} />
|
||||
{message}
|
||||
</span>
|
||||
)}
|
||||
</Message>
|
||||
}
|
||||
/>
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onRemove = (account) => (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onSwitch = account => event => {
|
||||
event.preventDefault();
|
||||
|
||||
this.props.removeAccount(account)
|
||||
.then(() => this.props.onAfterAction());
|
||||
};
|
||||
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 => event => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.props.removeAccount(account).then(() => this.props.onAfterAction());
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(({accounts}) => ({
|
||||
export default connect(
|
||||
({ accounts }) => ({
|
||||
accounts,
|
||||
}), {
|
||||
}),
|
||||
{
|
||||
switchAccount: authenticate,
|
||||
removeAccount: revoke
|
||||
})(AccountSwitcher);
|
||||
removeAccount: revoke,
|
||||
},
|
||||
)(AccountSwitcher);
|
||||
|
@@ -8,219 +8,218 @@ $bodyLeftRightPadding: 20px;
|
||||
$lightBorderColor: #eee;
|
||||
|
||||
.accountSwitcher {
|
||||
text-align: left;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.accountInfo {
|
||||
|
||||
}
|
||||
|
||||
.accountUsername,
|
||||
.accountEmail {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.lightAccountSwitcher {
|
||||
background: #fff;
|
||||
color: #444;
|
||||
min-width: 205px;
|
||||
background: #fff;
|
||||
color: #444;
|
||||
min-width: 205px;
|
||||
|
||||
$border: 1px solid $lightBorderColor;
|
||||
border-left: $border;
|
||||
border-right: $border;
|
||||
border-bottom: 7px solid darker($green);
|
||||
$border: 1px solid $lightBorderColor;
|
||||
border-left: $border;
|
||||
border-right: $border;
|
||||
border-bottom: 7px solid darker($green);
|
||||
|
||||
.item {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid $lightBorderColor;
|
||||
.item {
|
||||
padding: 15px;
|
||||
border-bottom: 1px solid $lightBorderColor;
|
||||
}
|
||||
|
||||
.accountSwitchItem {
|
||||
cursor: pointer;
|
||||
transition: 0.25s;
|
||||
|
||||
&:hover {
|
||||
background-color: $whiteButtonLight;
|
||||
}
|
||||
|
||||
.accountSwitchItem {
|
||||
cursor: pointer;
|
||||
transition: .25s;
|
||||
|
||||
&:hover {
|
||||
background-color: $whiteButtonLight;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $whiteButtonDark;
|
||||
}
|
||||
&:active {
|
||||
background-color: $whiteButtonDark;
|
||||
}
|
||||
}
|
||||
|
||||
.accountIcon {
|
||||
font-size: 27px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.activeAccountIcon {
|
||||
font-size: 40px;
|
||||
}
|
||||
.accountInfo {
|
||||
margin-left: 29px;
|
||||
margin-right: 25px;
|
||||
}
|
||||
|
||||
.activeAccountInfo {
|
||||
margin-left: 29px;
|
||||
}
|
||||
.accountUsername {
|
||||
font-family: $font-family-title;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.activeAccountUsername {
|
||||
font-family: $font-family-title;
|
||||
font-size: 20px;
|
||||
color: $green;
|
||||
}
|
||||
.accountEmail {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.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 {
|
||||
}
|
||||
.addAccount {
|
||||
}
|
||||
}
|
||||
|
||||
.darkAccountSwitcher {
|
||||
background: $black;
|
||||
background: $black;
|
||||
|
||||
$border: 1px solid lighter($black);
|
||||
$border: 1px solid lighter($black);
|
||||
|
||||
.item {
|
||||
padding: 15px 20px;
|
||||
border-top: 1px solid lighter($black);
|
||||
transition: .25s;
|
||||
cursor: pointer;
|
||||
.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;
|
||||
}
|
||||
&:hover {
|
||||
background-color: lighter($black);
|
||||
}
|
||||
|
||||
.accountIcon {
|
||||
font-size: 35px;
|
||||
&:active {
|
||||
background-color: darker($black);
|
||||
}
|
||||
|
||||
.accountInfo {
|
||||
margin-left: 30px;
|
||||
margin-right: 26px;
|
||||
&:last-of-type {
|
||||
border-bottom: $border;
|
||||
}
|
||||
}
|
||||
|
||||
.accountUsername {
|
||||
font-family: $font-family-title;
|
||||
color: #fff;
|
||||
}
|
||||
.accountIcon {
|
||||
font-size: 35px;
|
||||
}
|
||||
|
||||
.accountEmail {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
}
|
||||
.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 '~components/ui/icons.scss';
|
||||
composes: minecraft-character from '~components/ui/icons.scss';
|
||||
|
||||
float: left;
|
||||
float: left;
|
||||
|
||||
&1 {
|
||||
color: $green;
|
||||
}
|
||||
&1 {
|
||||
color: $green;
|
||||
}
|
||||
|
||||
&2 {
|
||||
color: $blue;
|
||||
}
|
||||
&2 {
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
&3 {
|
||||
color: $violet;
|
||||
}
|
||||
&3 {
|
||||
color: $violet;
|
||||
}
|
||||
|
||||
&4 {
|
||||
color: $orange;
|
||||
}
|
||||
&4 {
|
||||
color: $orange;
|
||||
}
|
||||
|
||||
&5 {
|
||||
color: $dark_blue;
|
||||
}
|
||||
&5 {
|
||||
color: $dark_blue;
|
||||
}
|
||||
|
||||
&6 {
|
||||
color: $light_violet;
|
||||
}
|
||||
&6 {
|
||||
color: $light_violet;
|
||||
}
|
||||
|
||||
&7 {
|
||||
color: $red;
|
||||
}
|
||||
&7 {
|
||||
color: $red;
|
||||
}
|
||||
}
|
||||
|
||||
.addIcon {
|
||||
composes: plus from '~components/ui/icons.scss';
|
||||
composes: plus from '~components/ui/icons.scss';
|
||||
|
||||
color: $green;
|
||||
position: relative;
|
||||
bottom: 1px;
|
||||
margin-right: 3px;
|
||||
color: $green;
|
||||
position: relative;
|
||||
bottom: 1px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
.nextIcon {
|
||||
composes: arrowRight from '~components/ui/icons.scss';
|
||||
composes: arrowRight from '~components/ui/icons.scss';
|
||||
|
||||
position: relative;
|
||||
float: right;
|
||||
position: relative;
|
||||
float: right;
|
||||
|
||||
font-size: 24px;
|
||||
color: #4E4E4E;
|
||||
line-height: 35px;
|
||||
left: 0;
|
||||
font-size: 24px;
|
||||
color: #4e4e4e;
|
||||
line-height: 35px;
|
||||
left: 0;
|
||||
|
||||
transition: color .25s, left .5s;
|
||||
transition: color 0.25s, left 0.5s;
|
||||
|
||||
.item:hover & {
|
||||
color: #aaa;
|
||||
left: 5px;
|
||||
}
|
||||
.item:hover & {
|
||||
color: #aaa;
|
||||
left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.logoutIcon {
|
||||
composes: exit from '~components/ui/icons.scss';
|
||||
composes: exit from '~components/ui/icons.scss';
|
||||
|
||||
color: #cdcdcd;
|
||||
float: right;
|
||||
line-height: 27px;
|
||||
transition: .25s;
|
||||
color: #cdcdcd;
|
||||
float: right;
|
||||
line-height: 27px;
|
||||
transition: 0.25s;
|
||||
|
||||
&:hover {
|
||||
color: #777;
|
||||
}
|
||||
&:hover {
|
||||
color: #777;
|
||||
}
|
||||
}
|
||||
|
@@ -2,7 +2,11 @@
|
||||
import type { Account, State as AccountsState } from './reducer';
|
||||
import { getJwtPayloads } from 'functions';
|
||||
import { sessionStorage } from 'services/localStorage';
|
||||
import { validateToken, requestToken, logout } from 'services/api/authentication';
|
||||
import {
|
||||
validateToken,
|
||||
requestToken,
|
||||
logout,
|
||||
} from 'services/api/authentication';
|
||||
import { relogin as navigateToLogin } from 'components/auth/actions';
|
||||
import { updateUser, setGuest } from 'components/user/actions';
|
||||
import { setLocale } from 'components/i18n/actions';
|
||||
@@ -11,22 +15,22 @@ import { getActiveAccount } from 'components/accounts/reducer';
|
||||
import logger from 'services/logger';
|
||||
|
||||
import {
|
||||
add,
|
||||
remove,
|
||||
activate,
|
||||
reset,
|
||||
updateToken
|
||||
add,
|
||||
remove,
|
||||
activate,
|
||||
reset,
|
||||
updateToken,
|
||||
} from './actions/pure-actions';
|
||||
|
||||
type Dispatch = (action: Object) => Promise<*>;
|
||||
|
||||
type State = {
|
||||
accounts: AccountsState,
|
||||
auth: {
|
||||
oauth?: {
|
||||
clientId?: string
|
||||
},
|
||||
accounts: AccountsState,
|
||||
auth: {
|
||||
oauth?: {
|
||||
clientId?: string,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export { updateToken, activate, remove };
|
||||
@@ -36,98 +40,112 @@ export { updateToken, activate, remove };
|
||||
* @param {string} account.token
|
||||
* @param {string} account.refreshToken
|
||||
*
|
||||
* @return {function}
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function authenticate(account: Account | {
|
||||
token: string,
|
||||
refreshToken: ?string,
|
||||
}) {
|
||||
const { token, refreshToken } = account;
|
||||
const email = account.email || null;
|
||||
export function authenticate(
|
||||
account:
|
||||
| Account
|
||||
| {
|
||||
token: string,
|
||||
refreshToken: ?string,
|
||||
},
|
||||
) {
|
||||
const { token, refreshToken } = account;
|
||||
const email = account.email || null;
|
||||
|
||||
return async (dispatch: Dispatch, getState: () => State): Promise<Account> => {
|
||||
let accountId: number;
|
||||
if (typeof account.id === 'number') {
|
||||
accountId = account.id;
|
||||
} else {
|
||||
accountId = findAccountIdFromToken(token);
|
||||
}
|
||||
return async (
|
||||
dispatch: Dispatch,
|
||||
getState: () => State,
|
||||
): Promise<Account> => {
|
||||
let accountId: number;
|
||||
|
||||
const knownAccount = getState().accounts.available.find((item) => item.id === accountId);
|
||||
if (knownAccount) {
|
||||
// this account is already available
|
||||
// activate it before validation
|
||||
dispatch(activate(knownAccount));
|
||||
}
|
||||
if (typeof account.id === 'number') {
|
||||
accountId = account.id;
|
||||
} else {
|
||||
accountId = findAccountIdFromToken(token);
|
||||
}
|
||||
|
||||
try {
|
||||
const {
|
||||
token: newToken,
|
||||
refreshToken: newRefreshToken,
|
||||
user,
|
||||
// $FlowFixMe have no idea why it's causes error about missing properties
|
||||
} = await validateToken(accountId, token, refreshToken);
|
||||
const { auth } = getState();
|
||||
const account: Account = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
token: newToken,
|
||||
refreshToken: newRefreshToken,
|
||||
};
|
||||
dispatch(add(account));
|
||||
dispatch(activate(account));
|
||||
dispatch(updateUser({
|
||||
isGuest: false,
|
||||
...user,
|
||||
}));
|
||||
const knownAccount = getState().accounts.available.find(
|
||||
item => item.id === accountId,
|
||||
);
|
||||
|
||||
// TODO: probably should be moved from here, because it is a side effect
|
||||
logger.setUser(user);
|
||||
if (knownAccount) {
|
||||
// this account is already available
|
||||
// activate it before validation
|
||||
dispatch(activate(knownAccount));
|
||||
}
|
||||
|
||||
if (!newRefreshToken) {
|
||||
// mark user as stranger (user does not want us to remember his account)
|
||||
sessionStorage.setItem(`stranger${account.id}`, 1);
|
||||
}
|
||||
try {
|
||||
const {
|
||||
token: newToken,
|
||||
refreshToken: newRefreshToken,
|
||||
user,
|
||||
// $FlowFixMe have no idea why it's causes error about missing properties
|
||||
} = await validateToken(accountId, token, refreshToken);
|
||||
const { auth } = getState();
|
||||
const account: Account = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
token: newToken,
|
||||
refreshToken: newRefreshToken,
|
||||
};
|
||||
dispatch(add(account));
|
||||
dispatch(activate(account));
|
||||
dispatch(
|
||||
updateUser({
|
||||
isGuest: false,
|
||||
...user,
|
||||
}),
|
||||
);
|
||||
|
||||
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));
|
||||
}
|
||||
// TODO: probably should be moved from here, because it is a side effect
|
||||
logger.setUser(user);
|
||||
|
||||
await dispatch(setLocale(user.lang));
|
||||
if (!newRefreshToken) {
|
||||
// mark user as stranger (user does not want us to remember his account)
|
||||
sessionStorage.setItem(`stranger${account.id}`, 1);
|
||||
}
|
||||
|
||||
return account;
|
||||
} 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));
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
throw resp;
|
||||
}
|
||||
};
|
||||
await dispatch(setLocale(user.lang));
|
||||
|
||||
return account;
|
||||
} 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
const { sub, jti } = getJwtPayloads(token);
|
||||
|
||||
// In older backend versions identity was stored in jti claim. Some users still have such tokens
|
||||
if (jti) {
|
||||
return jti;
|
||||
}
|
||||
// sub has the format "ely|{accountId}", so we must trim "ely|" part
|
||||
if (sub) {
|
||||
return parseInt(sub.substr(4), 10);
|
||||
}
|
||||
|
||||
throw new Error('payloads is not contains any identity claim');
|
||||
// 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');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,31 +154,31 @@ function findAccountIdFromToken(token: string): number {
|
||||
*
|
||||
* @see components/user/middlewares/refreshTokenMiddleware
|
||||
*
|
||||
* @return {function}
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function ensureToken() {
|
||||
return (dispatch: Dispatch, getState: () => State): Promise<void> => {
|
||||
const {token} = getActiveAccount(getState()) || {};
|
||||
return (dispatch: Dispatch, getState: () => State): Promise<void> => {
|
||||
const { token } = getActiveAccount(getState()) || {};
|
||||
|
||||
try {
|
||||
const SAFETY_FACTOR = 300; // ask new token earlier to overcome time desynchronization problem
|
||||
const { exp } = getJwtPayloads(token);
|
||||
try {
|
||||
const SAFETY_FACTOR = 300; // ask new token earlier to overcome time desynchronization problem
|
||||
const { exp } = getJwtPayloads(token);
|
||||
|
||||
if (exp - SAFETY_FACTOR < Date.now() / 1000) {
|
||||
return dispatch(requestNewToken());
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Refresh token error: bad token', {
|
||||
token
|
||||
});
|
||||
if (exp - SAFETY_FACTOR < Date.now() / 1000) {
|
||||
return dispatch(requestNewToken());
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Refresh token error: bad token', {
|
||||
token,
|
||||
});
|
||||
|
||||
dispatch(relogin());
|
||||
dispatch(relogin());
|
||||
|
||||
return Promise.reject(new Error('Invalid token'));
|
||||
}
|
||||
return Promise.reject(new Error('Invalid token'));
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
return Promise.resolve();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,70 +189,74 @@ export function ensureToken() {
|
||||
*
|
||||
* @param {object} error
|
||||
*
|
||||
* @return {function}
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function recoverFromTokenError(error: ?{
|
||||
export function recoverFromTokenError(
|
||||
error: ?{
|
||||
status: number,
|
||||
message: string,
|
||||
}) {
|
||||
return (dispatch: Dispatch, getState: () => State): Promise<void> => {
|
||||
if (error && error.status === 401) {
|
||||
const activeAccount = getActiveAccount(getState());
|
||||
},
|
||||
) {
|
||||
return (dispatch: Dispatch, getState: () => State): Promise<void> => {
|
||||
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());
|
||||
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());
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
};
|
||||
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
|
||||
*
|
||||
* @return {function}
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function requestNewToken() {
|
||||
return (dispatch: Dispatch, getState: () => State): Promise<void> => {
|
||||
const {refreshToken} = getActiveAccount(getState()) || {};
|
||||
return (dispatch: Dispatch, getState: () => State): Promise<void> => {
|
||||
const { refreshToken } = getActiveAccount(getState()) || {};
|
||||
|
||||
if (!refreshToken) {
|
||||
dispatch(relogin());
|
||||
if (!refreshToken) {
|
||||
dispatch(relogin());
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return requestToken(refreshToken)
|
||||
.then((token) => {
|
||||
dispatch(updateToken(token));
|
||||
})
|
||||
.catch((resp) => {
|
||||
// all the logic to get the valid token was failed,
|
||||
// looks like we have some problems with token
|
||||
// lets redirect to login page
|
||||
dispatch(relogin());
|
||||
return requestToken(refreshToken)
|
||||
.then(token => {
|
||||
dispatch(updateToken(token));
|
||||
})
|
||||
.catch(resp => {
|
||||
// all the logic to get the valid token was failed,
|
||||
// looks like we have some problems with token
|
||||
// lets redirect to login page
|
||||
dispatch(relogin());
|
||||
|
||||
return Promise.reject(resp);
|
||||
});
|
||||
};
|
||||
return Promise.reject(resp);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,62 +264,64 @@ export function requestNewToken() {
|
||||
*
|
||||
* @param {Account} account
|
||||
*
|
||||
* @return {function}
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function revoke(account: Account) {
|
||||
return (dispatch: Dispatch, getState: () => State): Promise<void> => {
|
||||
const accountToReplace: ?Account = getState().accounts.available.find(({id}) => id !== account.id);
|
||||
return (dispatch: Dispatch, getState: () => State): Promise<void> => {
|
||||
const accountToReplace: ?Account = getState().accounts.available.find(
|
||||
({ id }) => id !== account.id,
|
||||
);
|
||||
|
||||
if (accountToReplace) {
|
||||
return dispatch(authenticate(accountToReplace))
|
||||
.finally(() => {
|
||||
// we need to logout user, even in case, when we can
|
||||
// not authenticate him with new account
|
||||
logout(account.token)
|
||||
.catch(() => {
|
||||
// we don't care
|
||||
});
|
||||
dispatch(remove(account));
|
||||
})
|
||||
.catch(() => {
|
||||
// we don't care
|
||||
});
|
||||
}
|
||||
if (accountToReplace) {
|
||||
return 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 dispatch(logoutAll());
|
||||
};
|
||||
return dispatch(logoutAll());
|
||||
};
|
||||
}
|
||||
|
||||
export function relogin(email?: string) {
|
||||
return (dispatch: Dispatch, getState: () => State) => {
|
||||
const activeAccount = getActiveAccount(getState());
|
||||
return (dispatch: Dispatch, getState: () => State) => {
|
||||
const activeAccount = getActiveAccount(getState());
|
||||
|
||||
if (!email && activeAccount) {
|
||||
email = activeAccount.email;
|
||||
}
|
||||
if (!email && activeAccount) {
|
||||
email = activeAccount.email;
|
||||
}
|
||||
|
||||
dispatch(navigateToLogin(email || null));
|
||||
};
|
||||
dispatch(navigateToLogin(email || null));
|
||||
};
|
||||
}
|
||||
|
||||
export function logoutAll() {
|
||||
return (dispatch: Dispatch, getState: () => State): Promise<void> => {
|
||||
dispatch(setGuest());
|
||||
return (dispatch: Dispatch, getState: () => State): Promise<void> => {
|
||||
dispatch(setGuest());
|
||||
|
||||
const {accounts: {available}} = getState();
|
||||
const {
|
||||
accounts: { available },
|
||||
} = getState();
|
||||
|
||||
available.forEach((account) =>
|
||||
logout(account.token)
|
||||
.catch(() => {
|
||||
// we don't care
|
||||
})
|
||||
);
|
||||
available.forEach(account =>
|
||||
logout(account.token).catch(() => {
|
||||
// we don't care
|
||||
}),
|
||||
);
|
||||
|
||||
dispatch(reset());
|
||||
dispatch(relogin());
|
||||
dispatch(reset());
|
||||
dispatch(relogin());
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
return Promise.resolve();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,33 +330,37 @@ export function logoutAll() {
|
||||
* We detecting foreign accounts by the absence of refreshToken. The account
|
||||
* won't be removed, until key `stranger${account.id}` is present in sessionStorage
|
||||
*
|
||||
* @return {function}
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function logoutStrangers() {
|
||||
return (dispatch: Dispatch, getState: () => State): Promise<void> => {
|
||||
const {accounts: {available}} = getState();
|
||||
const activeAccount = getActiveAccount(getState());
|
||||
return (dispatch: Dispatch, getState: () => State): Promise<void> => {
|
||||
const {
|
||||
accounts: { available },
|
||||
} = getState();
|
||||
const activeAccount = getActiveAccount(getState());
|
||||
|
||||
const isStranger = ({refreshToken, id}: Account) => !refreshToken && !sessionStorage.getItem(`stranger${id}`);
|
||||
const isStranger = ({ refreshToken, id }: Account) =>
|
||||
!refreshToken && !sessionStorage.getItem(`stranger${id}`);
|
||||
|
||||
if (available.some(isStranger)) {
|
||||
const accountToReplace = available.filter((account) => !isStranger(account))[0];
|
||||
if (available.some(isStranger)) {
|
||||
const accountToReplace = available.filter(
|
||||
account => !isStranger(account),
|
||||
)[0];
|
||||
|
||||
if (accountToReplace) {
|
||||
available.filter(isStranger)
|
||||
.forEach((account) => {
|
||||
dispatch(remove(account));
|
||||
logout(account.token);
|
||||
});
|
||||
if (accountToReplace) {
|
||||
available.filter(isStranger).forEach(account => {
|
||||
dispatch(remove(account));
|
||||
logout(account.token);
|
||||
});
|
||||
|
||||
if (activeAccount && isStranger(activeAccount)) {
|
||||
return dispatch(authenticate(accountToReplace));
|
||||
}
|
||||
} else {
|
||||
return dispatch(logoutAll());
|
||||
}
|
||||
if (activeAccount && isStranger(activeAccount)) {
|
||||
return dispatch(authenticate(accountToReplace));
|
||||
}
|
||||
} else {
|
||||
return dispatch(logoutAll());
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
};
|
||||
return Promise.resolve();
|
||||
};
|
||||
}
|
||||
|
@@ -7,515 +7,481 @@ import { InternalServerError } from 'services/request';
|
||||
import { sessionStorage } from 'services/localStorage';
|
||||
import * as authentication from 'services/api/authentication';
|
||||
import {
|
||||
authenticate,
|
||||
revoke,
|
||||
logoutAll,
|
||||
logoutStrangers,
|
||||
authenticate,
|
||||
revoke,
|
||||
logoutAll,
|
||||
logoutStrangers,
|
||||
} from 'components/accounts/actions';
|
||||
import {
|
||||
add, ADD,
|
||||
activate, ACTIVATE,
|
||||
remove,
|
||||
reset,
|
||||
add,
|
||||
ADD,
|
||||
activate,
|
||||
ACTIVATE,
|
||||
remove,
|
||||
reset,
|
||||
} from 'components/accounts/actions/pure-actions';
|
||||
import { SET_LOCALE } from 'components/i18n/actions';
|
||||
|
||||
import { updateUser, setUser } from 'components/user/actions';
|
||||
import { setLogin, setAccountSwitcher } from 'components/auth/actions';
|
||||
|
||||
const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJlbHl8MSJ9.pRJ7vakt2eIscjqwG__KhSxKb3qwGsdBBeDbBffJs_I';
|
||||
const legacyToken = 'eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOjF9.cRF-sQNrwWQ94xCb3vWioVdjxAZeefEE7GMGwh7708o';
|
||||
const token =
|
||||
'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJlbHl8MSJ9.pRJ7vakt2eIscjqwG__KhSxKb3qwGsdBBeDbBffJs_I';
|
||||
const legacyToken =
|
||||
'eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOjF9.cRF-sQNrwWQ94xCb3vWioVdjxAZeefEE7GMGwh7708o';
|
||||
|
||||
const account = {
|
||||
id: 1,
|
||||
username: 'username',
|
||||
email: 'email@test.com',
|
||||
token,
|
||||
refreshToken: 'bar',
|
||||
id: 1,
|
||||
username: 'username',
|
||||
email: 'email@test.com',
|
||||
token,
|
||||
refreshToken: 'bar',
|
||||
};
|
||||
|
||||
const user = {
|
||||
id: 1,
|
||||
username: 'username',
|
||||
email: 'email@test.com',
|
||||
lang: 'be'
|
||||
id: 1,
|
||||
username: 'username',
|
||||
email: 'email@test.com',
|
||||
lang: 'be',
|
||||
};
|
||||
|
||||
describe('components/accounts/actions', () => {
|
||||
let dispatch;
|
||||
let getState;
|
||||
let dispatch;
|
||||
let getState;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch = sinon
|
||||
.spy(arg => (typeof arg === 'function' ? arg(dispatch, getState) : arg))
|
||||
.named('store.dispatch');
|
||||
getState = sinon.stub().named('store.getState');
|
||||
|
||||
getState.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.returns(Promise.resolve());
|
||||
authentication.validateToken.returns(
|
||||
Promise.resolve({
|
||||
token: account.token,
|
||||
refreshToken: account.refreshToken,
|
||||
user,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
authentication.validateToken.restore();
|
||||
authentication.logout.restore();
|
||||
browserHistory.push.restore();
|
||||
});
|
||||
|
||||
describe('#authenticate()', () => {
|
||||
it('should request user state using token', () =>
|
||||
authenticate(account)(dispatch, getState).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 })(dispatch, getState).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 })(dispatch, getState).then(() =>
|
||||
expect(authentication.validateToken, 'to have a call satisfying', [
|
||||
1,
|
||||
legacyToken,
|
||||
undefined,
|
||||
]),
|
||||
));
|
||||
|
||||
it(`dispatches ${ADD} action`, () =>
|
||||
authenticate(account)(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [add(account)]),
|
||||
));
|
||||
|
||||
it(`dispatches ${ACTIVATE} action`, () =>
|
||||
authenticate(account)(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [activate(account)]),
|
||||
));
|
||||
|
||||
it(`dispatches ${SET_LOCALE} action`, () =>
|
||||
authenticate(account)(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
{ type: SET_LOCALE, payload: { locale: 'be' } },
|
||||
]),
|
||||
));
|
||||
|
||||
it('should update user state', () =>
|
||||
authenticate(account)(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
updateUser({ ...user, isGuest: false }),
|
||||
]),
|
||||
));
|
||||
|
||||
it('resolves with account', () =>
|
||||
authenticate(account)(dispatch, getState).then(resp =>
|
||||
expect(resp, 'to equal', account),
|
||||
));
|
||||
|
||||
it('rejects when bad auth data', () => {
|
||||
authentication.validateToken.returns(Promise.reject({}));
|
||||
|
||||
return expect(
|
||||
authenticate(account)(dispatch, getState),
|
||||
'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(null, { status: 500 });
|
||||
|
||||
authentication.validateToken.rejects(resp);
|
||||
|
||||
return expect(
|
||||
authenticate(account)(dispatch, getState),
|
||||
'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.resolves({
|
||||
token: account.token,
|
||||
user,
|
||||
});
|
||||
|
||||
sessionStorage.removeItem(expectedKey);
|
||||
|
||||
return authenticate(account)(dispatch, getState).then(() => {
|
||||
expect(sessionStorage.getItem(expectedKey), 'not to be null');
|
||||
sessionStorage.removeItem(expectedKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when user authenticated during oauth', () => {
|
||||
beforeEach(() => {
|
||||
getState.returns({
|
||||
accounts: {
|
||||
available: [],
|
||||
active: null,
|
||||
},
|
||||
user: {},
|
||||
auth: {
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
prompt: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch setAccountSwitcher', () =>
|
||||
authenticate(account)(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
setAccountSwitcher(false),
|
||||
]),
|
||||
));
|
||||
});
|
||||
|
||||
describe('when one account available', () => {
|
||||
beforeEach(() => {
|
||||
getState.returns({
|
||||
accounts: {
|
||||
active: account.id,
|
||||
available: [account],
|
||||
},
|
||||
auth: {
|
||||
credentials: {},
|
||||
},
|
||||
user,
|
||||
});
|
||||
});
|
||||
|
||||
it('should activate account before auth api call', () => {
|
||||
authentication.validateToken.returns(Promise.reject({ error: 'foo' }));
|
||||
|
||||
return expect(
|
||||
authenticate(account)(dispatch, getState),
|
||||
'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.returns({
|
||||
accounts: {
|
||||
active: account.id,
|
||||
available: [account],
|
||||
},
|
||||
auth: {
|
||||
credentials: {},
|
||||
},
|
||||
user,
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch reset action', () =>
|
||||
revoke(account)(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [reset()]),
|
||||
));
|
||||
|
||||
it('should call logout api method in background', () =>
|
||||
revoke(account)(dispatch, getState).then(() =>
|
||||
expect(authentication.logout, 'to have a call satisfying', [
|
||||
account.token,
|
||||
]),
|
||||
));
|
||||
|
||||
it('should update user state', () =>
|
||||
revoke(account)(dispatch, getState).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.returns({
|
||||
accounts: {
|
||||
active: account2.id,
|
||||
available: [account, account2],
|
||||
},
|
||||
user,
|
||||
});
|
||||
});
|
||||
|
||||
it('should switch to the next account', () =>
|
||||
revoke(account2)(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [activate(account)]),
|
||||
));
|
||||
|
||||
it('should remove current account', () =>
|
||||
revoke(account2)(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [remove(account2)]),
|
||||
));
|
||||
|
||||
it('should call logout api method in background', () =>
|
||||
revoke(account2)(dispatch, getState).then(() =>
|
||||
expect(authentication.logout, 'to have a call satisfying', [
|
||||
account2.token,
|
||||
]),
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
describe('#logoutAll()', () => {
|
||||
const account2 = { ...account, id: 2 };
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch = sinon.spy((arg) =>
|
||||
typeof arg === 'function' ? arg(dispatch, getState) : arg
|
||||
).named('store.dispatch');
|
||||
getState = sinon.stub().named('store.getState');
|
||||
getState.returns({
|
||||
accounts: {
|
||||
active: account2.id,
|
||||
available: [account, account2],
|
||||
},
|
||||
auth: {
|
||||
credentials: {},
|
||||
},
|
||||
user,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call logout api method for each account', () => {
|
||||
logoutAll()(dispatch, getState);
|
||||
|
||||
expect(authentication.logout, 'to have calls satisfying', [
|
||||
[account.token],
|
||||
[account2.token],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should dispatch reset', () => {
|
||||
logoutAll()(dispatch, getState);
|
||||
|
||||
expect(dispatch, 'to have a call satisfying', [reset()]);
|
||||
});
|
||||
|
||||
it('should redirect to /login', () =>
|
||||
logoutAll()(dispatch, getState).then(() => {
|
||||
expect(browserHistory.push, 'to have a call satisfying', ['/login']);
|
||||
}));
|
||||
|
||||
it('should change user to guest', () =>
|
||||
logoutAll()(dispatch, getState).then(() => {
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
setUser({
|
||||
lang: user.lang,
|
||||
isGuest: true,
|
||||
}),
|
||||
]);
|
||||
}));
|
||||
});
|
||||
|
||||
describe('#logoutStrangers', () => {
|
||||
const foreignAccount = {
|
||||
...account,
|
||||
id: 2,
|
||||
refreshToken: undefined,
|
||||
};
|
||||
|
||||
const foreignAccount2 = {
|
||||
...foreignAccount,
|
||||
id: 3,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getState.returns({
|
||||
accounts: {
|
||||
active: foreignAccount.id,
|
||||
available: [account, foreignAccount, foreignAccount2],
|
||||
},
|
||||
user,
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove stranger accounts', () => {
|
||||
logoutStrangers()(dispatch, getState);
|
||||
|
||||
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);
|
||||
|
||||
expect(authentication.logout, 'to have calls satisfying', [
|
||||
[foreignAccount.token],
|
||||
[foreignAccount2.token],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should activate another account if available', () =>
|
||||
logoutStrangers()(dispatch, getState).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.returns({
|
||||
accounts: {
|
||||
active: account.id,
|
||||
available: [account, foreignAccount],
|
||||
},
|
||||
user,
|
||||
});
|
||||
|
||||
return logoutStrangers()(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'not to have calls satisfying', [activate(account)]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not dispatch if no strangers', () => {
|
||||
getState.returns({
|
||||
accounts: {
|
||||
active: account.id,
|
||||
available: [account],
|
||||
},
|
||||
user,
|
||||
});
|
||||
|
||||
return logoutStrangers()(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'was not called'),
|
||||
);
|
||||
});
|
||||
|
||||
describe('when all accounts are strangers', () => {
|
||||
beforeEach(() => {
|
||||
getState.returns({
|
||||
accounts: {
|
||||
available: [],
|
||||
active: null
|
||||
},
|
||||
auth: {
|
||||
credentials: {},
|
||||
},
|
||||
user: {},
|
||||
accounts: {
|
||||
active: foreignAccount.id,
|
||||
available: [foreignAccount, foreignAccount2],
|
||||
},
|
||||
auth: {
|
||||
credentials: {},
|
||||
},
|
||||
user,
|
||||
});
|
||||
|
||||
sinon.stub(authentication, 'validateToken').named('authentication.validateToken');
|
||||
sinon.stub(browserHistory, 'push').named('browserHistory.push');
|
||||
sinon.stub(authentication, 'logout').named('authentication.logout');
|
||||
logoutStrangers()(dispatch, getState);
|
||||
});
|
||||
|
||||
authentication.logout.returns(Promise.resolve());
|
||||
authentication.validateToken.returns(Promise.resolve({
|
||||
token: account.token,
|
||||
refreshToken: account.refreshToken,
|
||||
user,
|
||||
}));
|
||||
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()]);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
authentication.validateToken.restore();
|
||||
authentication.logout.restore();
|
||||
browserHistory.push.restore();
|
||||
});
|
||||
|
||||
describe('#authenticate()', () => {
|
||||
it('should request user state using token', () =>
|
||||
authenticate(account)(dispatch, getState).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 })(dispatch, getState).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 })(dispatch, getState).then(() =>
|
||||
expect(authentication.validateToken, 'to have a call satisfying', [
|
||||
1,
|
||||
legacyToken,
|
||||
undefined,
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
it(`dispatches ${ADD} action`, () =>
|
||||
authenticate(account)(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
add(account)
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
it(`dispatches ${ACTIVATE} action`, () =>
|
||||
authenticate(account)(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
activate(account)
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
it(`dispatches ${SET_LOCALE} action`, () =>
|
||||
authenticate(account)(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
{type: SET_LOCALE, payload: {locale: 'be'}}
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
it('should update user state', () =>
|
||||
authenticate(account)(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
updateUser({...user, isGuest: false})
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
it('resolves with account', () =>
|
||||
authenticate(account)(dispatch, getState).then((resp) =>
|
||||
expect(resp, 'to equal', account)
|
||||
)
|
||||
);
|
||||
|
||||
it('rejects when bad auth data', () => {
|
||||
authentication.validateToken.returns(Promise.reject({}));
|
||||
|
||||
return expect(authenticate(account)(dispatch, getState), '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(null, {status: 500});
|
||||
|
||||
authentication.validateToken.rejects(resp);
|
||||
|
||||
return expect(authenticate(account)(dispatch, getState), '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.resolves({
|
||||
token: account.token,
|
||||
user,
|
||||
});
|
||||
|
||||
sessionStorage.removeItem(expectedKey);
|
||||
|
||||
return authenticate(account)(dispatch, getState).then(() => {
|
||||
expect(sessionStorage.getItem(expectedKey), 'not to be null');
|
||||
sessionStorage.removeItem(expectedKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when user authenticated during oauth', () => {
|
||||
beforeEach(() => {
|
||||
getState.returns({
|
||||
accounts: {
|
||||
available: [],
|
||||
active: null
|
||||
},
|
||||
user: {},
|
||||
auth: {
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
prompt: []
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch setAccountSwitcher', () =>
|
||||
authenticate(account)(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
setAccountSwitcher(false)
|
||||
])
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
describe('when one account available', () => {
|
||||
beforeEach(() => {
|
||||
getState.returns({
|
||||
accounts: {
|
||||
active: account.id,
|
||||
available: [account]
|
||||
},
|
||||
auth: {
|
||||
credentials: {},
|
||||
},
|
||||
user,
|
||||
});
|
||||
});
|
||||
|
||||
it('should activate account before auth api call', () => {
|
||||
authentication.validateToken.returns(Promise.reject({ error: 'foo'}));
|
||||
|
||||
return expect(
|
||||
authenticate(account)(dispatch, getState),
|
||||
'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.returns({
|
||||
accounts: {
|
||||
active: account.id,
|
||||
available: [account]
|
||||
},
|
||||
auth: {
|
||||
credentials: {},
|
||||
},
|
||||
user,
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch reset action', () =>
|
||||
revoke(account)(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
reset()
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
it('should call logout api method in background', () =>
|
||||
revoke(account)(dispatch, getState).then(() =>
|
||||
expect(authentication.logout, 'to have a call satisfying', [
|
||||
account.token
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
it('should update user state', () =>
|
||||
revoke(account)(dispatch, getState).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.returns({
|
||||
accounts: {
|
||||
active: account2.id,
|
||||
available: [account, account2]
|
||||
},
|
||||
user
|
||||
});
|
||||
});
|
||||
|
||||
it('should switch to the next account', () =>
|
||||
revoke(account2)(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
activate(account)
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
it('should remove current account', () =>
|
||||
revoke(account2)(dispatch, getState).then(() =>
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
remove(account2)
|
||||
])
|
||||
)
|
||||
);
|
||||
|
||||
it('should call logout api method in background', () =>
|
||||
revoke(account2)(dispatch, getState).then(() =>
|
||||
expect(authentication.logout, 'to have a call satisfying', [
|
||||
account2.token
|
||||
])
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#logoutAll()', () => {
|
||||
const account2 = {...account, id: 2};
|
||||
|
||||
beforeEach(() => {
|
||||
getState.returns({
|
||||
accounts: {
|
||||
active: account2.id,
|
||||
available: [account, account2]
|
||||
},
|
||||
auth: {
|
||||
credentials: {},
|
||||
},
|
||||
user,
|
||||
});
|
||||
});
|
||||
|
||||
it('should call logout api method for each account', () => {
|
||||
logoutAll()(dispatch, getState);
|
||||
|
||||
expect(authentication.logout, 'to have calls satisfying', [
|
||||
[account.token],
|
||||
[account2.token]
|
||||
]);
|
||||
});
|
||||
|
||||
it('should dispatch reset', () => {
|
||||
logoutAll()(dispatch, getState);
|
||||
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
reset()
|
||||
]);
|
||||
});
|
||||
|
||||
it('should redirect to /login', () =>
|
||||
logoutAll()(dispatch, getState).then(() => {
|
||||
expect(browserHistory.push, 'to have a call satisfying', [
|
||||
'/login'
|
||||
]);
|
||||
})
|
||||
);
|
||||
|
||||
it('should change user to guest', () =>
|
||||
logoutAll()(dispatch, getState).then(() => {
|
||||
expect(dispatch, 'to have a call satisfying', [
|
||||
setUser({
|
||||
lang: user.lang,
|
||||
isGuest: true
|
||||
})
|
||||
]);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('#logoutStrangers', () => {
|
||||
const foreignAccount = {
|
||||
...account,
|
||||
id: 2,
|
||||
refreshToken: undefined
|
||||
};
|
||||
|
||||
const foreignAccount2 = {
|
||||
...foreignAccount,
|
||||
id: 3
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getState.returns({
|
||||
accounts: {
|
||||
active: foreignAccount.id,
|
||||
available: [account, foreignAccount, foreignAccount2]
|
||||
},
|
||||
user,
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove stranger accounts', () => {
|
||||
logoutStrangers()(dispatch, getState);
|
||||
|
||||
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);
|
||||
|
||||
expect(authentication.logout, 'to have calls satisfying', [
|
||||
[foreignAccount.token],
|
||||
[foreignAccount2.token]
|
||||
]);
|
||||
});
|
||||
|
||||
it('should activate another account if available', () =>
|
||||
logoutStrangers()(dispatch, getState)
|
||||
.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.returns({
|
||||
accounts: {
|
||||
active: account.id,
|
||||
available: [account, foreignAccount]
|
||||
},
|
||||
user
|
||||
});
|
||||
|
||||
return logoutStrangers()(dispatch, getState)
|
||||
.then(() =>
|
||||
expect(dispatch, 'not to have calls satisfying',
|
||||
[activate(account)]
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
it('should not dispatch if no strangers', () => {
|
||||
getState.returns({
|
||||
accounts: {
|
||||
active: account.id,
|
||||
available: [account]
|
||||
},
|
||||
user
|
||||
});
|
||||
|
||||
return logoutStrangers()(dispatch, getState)
|
||||
.then(() =>
|
||||
expect(dispatch, 'was not called')
|
||||
);
|
||||
});
|
||||
|
||||
describe('when all accounts are strangers', () => {
|
||||
beforeEach(() => {
|
||||
getState.returns({
|
||||
accounts: {
|
||||
active: foreignAccount.id,
|
||||
available: [foreignAccount, foreignAccount2]
|
||||
},
|
||||
auth: {
|
||||
credentials: {},
|
||||
},
|
||||
user,
|
||||
});
|
||||
|
||||
logoutStrangers()(dispatch, getState);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.removeItem(key);
|
||||
});
|
||||
|
||||
it('should not log out', () =>
|
||||
expect(dispatch, 'not to have calls satisfying',
|
||||
[{payload: foreignAccount}]
|
||||
)
|
||||
);
|
||||
});
|
||||
describe('when a stranger has a mark in sessionStorage', () => {
|
||||
const key = `stranger${foreignAccount.id}`;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.setItem(key, 1);
|
||||
|
||||
logoutStrangers()(dispatch, getState);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.removeItem(key);
|
||||
});
|
||||
|
||||
it('should not log out', () =>
|
||||
expect(dispatch, 'not to have calls satisfying', [
|
||||
{ payload: foreignAccount },
|
||||
]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,11 +1,11 @@
|
||||
// @flow
|
||||
import type {
|
||||
Account,
|
||||
AddAction,
|
||||
RemoveAction,
|
||||
ActivateAction,
|
||||
UpdateTokenAction,
|
||||
ResetAction
|
||||
Account,
|
||||
AddAction,
|
||||
RemoveAction,
|
||||
ActivateAction,
|
||||
UpdateTokenAction,
|
||||
ResetAction,
|
||||
} from '../reducer';
|
||||
|
||||
export const ADD = 'accounts:add';
|
||||
@@ -14,13 +14,13 @@ export const ADD = 'accounts:add';
|
||||
*
|
||||
* @param {Account} account
|
||||
*
|
||||
* @return {object} - action definition
|
||||
* @returns {object} - action definition
|
||||
*/
|
||||
export function add(account: Account): AddAction {
|
||||
return {
|
||||
type: ADD,
|
||||
payload: account
|
||||
};
|
||||
return {
|
||||
type: ADD,
|
||||
payload: account,
|
||||
};
|
||||
}
|
||||
|
||||
export const REMOVE = 'accounts:remove';
|
||||
@@ -29,13 +29,13 @@ export const REMOVE = 'accounts:remove';
|
||||
*
|
||||
* @param {Account} account
|
||||
*
|
||||
* @return {object} - action definition
|
||||
* @returns {object} - action definition
|
||||
*/
|
||||
export function remove(account: Account): RemoveAction {
|
||||
return {
|
||||
type: REMOVE,
|
||||
payload: account
|
||||
};
|
||||
return {
|
||||
type: REMOVE,
|
||||
payload: account,
|
||||
};
|
||||
}
|
||||
|
||||
export const ACTIVATE = 'accounts:activate';
|
||||
@@ -44,36 +44,36 @@ export const ACTIVATE = 'accounts:activate';
|
||||
*
|
||||
* @param {Account} account
|
||||
*
|
||||
* @return {object} - action definition
|
||||
* @returns {object} - action definition
|
||||
*/
|
||||
export function activate(account: Account): ActivateAction {
|
||||
return {
|
||||
type: ACTIVATE,
|
||||
payload: account
|
||||
};
|
||||
return {
|
||||
type: ACTIVATE,
|
||||
payload: account,
|
||||
};
|
||||
}
|
||||
|
||||
export const RESET = 'accounts:reset';
|
||||
/**
|
||||
* @api private
|
||||
*
|
||||
* @return {object} - action definition
|
||||
* @returns {object} - action definition
|
||||
*/
|
||||
export function reset(): ResetAction {
|
||||
return {
|
||||
type: RESET
|
||||
};
|
||||
return {
|
||||
type: RESET,
|
||||
};
|
||||
}
|
||||
|
||||
export const UPDATE_TOKEN = 'accounts:updateToken';
|
||||
/**
|
||||
* @param {string} token
|
||||
*
|
||||
* @return {object} - action definition
|
||||
* @returns {object} - action definition
|
||||
*/
|
||||
export function updateToken(token: string): UpdateTokenAction {
|
||||
return {
|
||||
type: UPDATE_TOKEN,
|
||||
payload: token
|
||||
};
|
||||
return {
|
||||
type: UPDATE_TOKEN,
|
||||
payload: token,
|
||||
};
|
||||
}
|
||||
|
@@ -1,152 +1,138 @@
|
||||
// @flow
|
||||
export type Account = {
|
||||
id: number,
|
||||
username: string,
|
||||
email: string,
|
||||
token: string,
|
||||
refreshToken: ?string
|
||||
id: number,
|
||||
username: string,
|
||||
email: string,
|
||||
token: string,
|
||||
refreshToken: ?string,
|
||||
};
|
||||
|
||||
export type State = {
|
||||
active: ?number,
|
||||
available: Array<Account>
|
||||
active: ?number,
|
||||
available: Array<Account>,
|
||||
};
|
||||
|
||||
export type AddAction = { type: 'accounts:add', payload: Account };
|
||||
export type RemoveAction = { type: 'accounts:remove', payload: Account };
|
||||
export type ActivateAction = { type: 'accounts:activate', payload: Account };
|
||||
export type UpdateTokenAction = {
|
||||
type: 'accounts:updateToken',
|
||||
payload: string
|
||||
type: 'accounts:updateToken',
|
||||
payload: string,
|
||||
};
|
||||
export type ResetAction = { type: 'accounts:reset' };
|
||||
|
||||
type Action =
|
||||
| AddAction
|
||||
| RemoveAction
|
||||
| ActivateAction
|
||||
| UpdateTokenAction
|
||||
| ResetAction;
|
||||
| AddAction
|
||||
| RemoveAction
|
||||
| ActivateAction
|
||||
| UpdateTokenAction
|
||||
| ResetAction;
|
||||
|
||||
export function getActiveAccount(state: { accounts: State }): ?Account {
|
||||
const accountId = state.accounts.active;
|
||||
const accountId = state.accounts.active;
|
||||
|
||||
return state.accounts.available.find((account) => account.id === accountId);
|
||||
return state.accounts.available.find(account => account.id === accountId);
|
||||
}
|
||||
|
||||
export function getAvailableAccounts(state: {
|
||||
accounts: State
|
||||
accounts: State,
|
||||
}): Array<Account> {
|
||||
return state.accounts.available;
|
||||
return state.accounts.available;
|
||||
}
|
||||
|
||||
export default function accounts(
|
||||
state: State = {
|
||||
active: null,
|
||||
available: []
|
||||
},
|
||||
action: Action
|
||||
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;
|
||||
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');
|
||||
}
|
||||
|
||||
state.available = state.available
|
||||
.filter((account) => account.id !== payload.id)
|
||||
.concat(payload);
|
||||
const { payload } = action;
|
||||
|
||||
state.available.sort((account1, account2) => {
|
||||
if (account1.username === account2.username) {
|
||||
return 0;
|
||||
}
|
||||
state.available = state.available
|
||||
.filter(account => account.id !== payload.id)
|
||||
.concat(payload);
|
||||
|
||||
return account1.username > account2.username ? 1 : -1;
|
||||
});
|
||||
|
||||
return state;
|
||||
state.available.sort((account1, account2) => {
|
||||
if (account1.username === account2.username) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
case 'accounts:activate': {
|
||||
if (
|
||||
!action.payload
|
||||
|| !action.payload.id
|
||||
|| !action.payload.token
|
||||
) {
|
||||
throw new Error(
|
||||
'Invalid or empty payload passed for accounts.add'
|
||||
);
|
||||
}
|
||||
return account1.username > account2.username ? 1 : -1;
|
||||
});
|
||||
|
||||
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 };
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
(action: empty);
|
||||
return state;
|
||||
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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
(action: empty);
|
||||
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
@@ -1,122 +1,150 @@
|
||||
import expect from 'test/unexpected';
|
||||
|
||||
import accounts from 'components/accounts/reducer';
|
||||
import { updateToken } from 'components/accounts/actions';
|
||||
import {
|
||||
updateToken
|
||||
} from 'components/accounts/actions';
|
||||
import {
|
||||
add, remove, activate, reset,
|
||||
ADD, REMOVE, ACTIVATE, UPDATE_TOKEN, RESET
|
||||
add,
|
||||
remove,
|
||||
activate,
|
||||
reset,
|
||||
ADD,
|
||||
REMOVE,
|
||||
ACTIVATE,
|
||||
UPDATE_TOKEN,
|
||||
RESET,
|
||||
} from 'components/accounts/actions/pure-actions';
|
||||
|
||||
const account = {
|
||||
id: 1,
|
||||
username: 'username',
|
||||
email: 'email@test.com',
|
||||
token: 'foo'
|
||||
id: 1,
|
||||
username: 'username',
|
||||
email: 'email@test.com',
|
||||
token: 'foo',
|
||||
};
|
||||
|
||||
describe('Accounts reducer', () => {
|
||||
let initial;
|
||||
let initial;
|
||||
|
||||
beforeEach(() => {
|
||||
initial = accounts(undefined, {});
|
||||
});
|
||||
beforeEach(() => {
|
||||
initial = accounts(undefined, {});
|
||||
});
|
||||
|
||||
it('should be empty', () => expect(accounts(undefined, {}), 'to equal', {
|
||||
active: null,
|
||||
available: []
|
||||
it('should be empty', () =>
|
||||
expect(accounts(undefined, {}), 'to equal', {
|
||||
active: null,
|
||||
available: [],
|
||||
}));
|
||||
|
||||
it('should return last state if unsupported action', () =>
|
||||
expect(accounts({state: 'foo'}, {}), 'to equal', {state: 'foo'})
|
||||
);
|
||||
it('should return last state if unsupported action', () =>
|
||||
expect(accounts({ state: 'foo' }, {}), 'to equal', { state: 'foo' }));
|
||||
|
||||
describe(ACTIVATE, () => {
|
||||
it('sets active account', () => {
|
||||
expect(accounts(initial, activate(account)), 'to satisfy', {
|
||||
active: account.id
|
||||
});
|
||||
});
|
||||
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],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe(ADD, () => {
|
||||
it('adds an account', () =>
|
||||
expect(accounts(initial, add(account)), 'to satisfy', {
|
||||
available: [account]
|
||||
})
|
||||
);
|
||||
it('should sort accounts by username', () => {
|
||||
const newAccount = {
|
||||
...account,
|
||||
id: 2,
|
||||
username: 'abc',
|
||||
};
|
||||
|
||||
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, add()),
|
||||
'to throw', 'Invalid or empty payload passed for accounts.add');
|
||||
});
|
||||
expect(
|
||||
accounts({ ...initial, available: [account] }, add(newAccount)),
|
||||
'to satisfy',
|
||||
{
|
||||
available: [newAccount, account],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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, remove()),
|
||||
'to throw', 'Invalid or empty payload passed for accounts.remove');
|
||||
});
|
||||
it('throws, when account is invalid', () => {
|
||||
expect(
|
||||
() => accounts(initial, add()),
|
||||
'to throw',
|
||||
'Invalid or empty payload passed for accounts.add',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(RESET, () => {
|
||||
it('should reset accounts state', () =>
|
||||
expect(accounts({...initial, available: [account]}, reset()),
|
||||
'to equal', initial)
|
||||
);
|
||||
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, remove()),
|
||||
'to throw',
|
||||
'Invalid or empty payload passed for accounts.remove',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(UPDATE_TOKEN, () => {
|
||||
it('should update token', () => {
|
||||
const newToken = 'newToken';
|
||||
describe(RESET, () => {
|
||||
it('should reset accounts state', () =>
|
||||
expect(
|
||||
accounts({ ...initial, available: [account] }, reset()),
|
||||
'to equal',
|
||||
initial,
|
||||
));
|
||||
});
|
||||
|
||||
expect(accounts(
|
||||
{active: account.id, available: [account]},
|
||||
updateToken(newToken)
|
||||
), 'to satisfy', {
|
||||
active: account.id,
|
||||
available: [{
|
||||
...account,
|
||||
token: newToken
|
||||
}]
|
||||
});
|
||||
});
|
||||
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,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -4,10 +4,15 @@ import React from 'react';
|
||||
import Helmet from 'react-helmet';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
|
||||
export default function AuthTitle({title}: {title: {id: string}}) {
|
||||
return (
|
||||
<Message {...title}>
|
||||
{(msg) => <span>{msg}<Helmet title={msg} /></span>}
|
||||
</Message>
|
||||
);
|
||||
export default function AuthTitle({ title }: { title: { id: string } }) {
|
||||
return (
|
||||
<Message {...title}>
|
||||
{msg => (
|
||||
<span>
|
||||
{msg}
|
||||
<Helmet title={msg} />
|
||||
</span>
|
||||
)}
|
||||
</Message>
|
||||
);
|
||||
}
|
||||
|
@@ -9,51 +9,57 @@ import { userShape } from 'components/user/User';
|
||||
import { FormModel } from 'components/ui/form';
|
||||
|
||||
export default class BaseAuthBody extends Component {
|
||||
static contextTypes = {
|
||||
clearErrors: PropTypes.func.isRequired,
|
||||
resolve: PropTypes.func.isRequired,
|
||||
requestRedraw: PropTypes.func.isRequired,
|
||||
auth: PropTypes.shape({
|
||||
error: PropTypes.oneOfType([PropTypes.string, PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
payload: PropTypes.object
|
||||
})]),
|
||||
scopes: PropTypes.array
|
||||
}).isRequired,
|
||||
user: userShape
|
||||
};
|
||||
static contextTypes = {
|
||||
clearErrors: PropTypes.func.isRequired,
|
||||
resolve: PropTypes.func.isRequired,
|
||||
requestRedraw: PropTypes.func.isRequired,
|
||||
auth: PropTypes.shape({
|
||||
error: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
payload: PropTypes.object,
|
||||
}),
|
||||
]),
|
||||
scopes: PropTypes.array,
|
||||
}).isRequired,
|
||||
user: userShape,
|
||||
};
|
||||
|
||||
componentWillReceiveProps(nextProps, nextContext) {
|
||||
if (nextContext.auth.error !== this.context.auth.error) {
|
||||
this.form.setErrors(nextContext.auth.error || {});
|
||||
}
|
||||
componentWillReceiveProps(nextProps, nextContext) {
|
||||
if (nextContext.auth.error !== this.context.auth.error) {
|
||||
this.form.setErrors(nextContext.auth.error || {});
|
||||
}
|
||||
}
|
||||
|
||||
renderErrors() {
|
||||
return this.form.hasErrors()
|
||||
? <AuthError error={this.form.getFirstError()} onClose={this.onClearErrors} />
|
||||
: null;
|
||||
}
|
||||
renderErrors() {
|
||||
return this.form.hasErrors() ? (
|
||||
<AuthError
|
||||
error={this.form.getFirstError()}
|
||||
onClose={this.onClearErrors}
|
||||
/>
|
||||
) : null;
|
||||
}
|
||||
|
||||
onFormSubmit() {
|
||||
this.context.resolve(this.serialize());
|
||||
}
|
||||
onFormSubmit() {
|
||||
this.context.resolve(this.serialize());
|
||||
}
|
||||
|
||||
onClearErrors = this.context.clearErrors;
|
||||
onClearErrors = this.context.clearErrors;
|
||||
|
||||
form = new FormModel({
|
||||
renderErrors: false
|
||||
});
|
||||
form = new FormModel({
|
||||
renderErrors: false,
|
||||
});
|
||||
|
||||
bindField = this.form.bindField.bind(this.form);
|
||||
bindField = this.form.bindField.bind(this.form);
|
||||
|
||||
serialize() {
|
||||
return this.form.serialize();
|
||||
}
|
||||
serialize() {
|
||||
return this.form.serialize();
|
||||
}
|
||||
|
||||
autoFocus() {
|
||||
const fieldId = this.autoFocusField;
|
||||
autoFocus() {
|
||||
const fieldId = this.autoFocusField;
|
||||
|
||||
fieldId && this.form.focus(fieldId);
|
||||
}
|
||||
fieldId && this.form.focus(fieldId);
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,16 @@
|
||||
|
||||
To add new panel you need to:
|
||||
|
||||
* create panel component at `components/auth/[panelId]`
|
||||
* add new context in `components/auth/PanelTransition`
|
||||
* connect component to router in `pages/auth/AuthPage`
|
||||
* add new state to `services/authFlow` and coresponding test to `tests/services/authFlow`
|
||||
* connect state to `authFlow`. Update `services/authFlow/AuthFlow.test` and `services/authFlow/AuthFlow.functional.test` (the last one for some complex flow)
|
||||
* add new actions to `components/auth/actions` and api endpoints to `services/api`
|
||||
* whatever else you need
|
||||
- create panel component at `components/auth/[panelId]`
|
||||
- add new context in `components/auth/PanelTransition`
|
||||
- connect component to router in `pages/auth/AuthPage`
|
||||
- add new state to `services/authFlow` and coresponding test to
|
||||
`tests/services/authFlow`
|
||||
- connect state to `authFlow`. Update `services/authFlow/AuthFlow.test` and
|
||||
`services/authFlow/AuthFlow.functional.test` (the last one for some complex
|
||||
flow)
|
||||
- add new actions to `components/auth/actions` and api endpoints to
|
||||
`services/api`
|
||||
- whatever else you need
|
||||
|
||||
Commit id with example implementation: f4d315c
|
||||
|
@@ -6,33 +6,36 @@ import { FormattedMessage as Message } from 'react-intl';
|
||||
import { userShape } from 'components/user/User';
|
||||
|
||||
export default function RejectionLink(props, 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;
|
||||
}
|
||||
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();
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
|
||||
context.reject(props.payload);
|
||||
}}>
|
||||
<Message {...props.label} />
|
||||
</a>
|
||||
);
|
||||
context.reject(props.payload);
|
||||
}}
|
||||
>
|
||||
<Message {...props.label} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
RejectionLink.displayName = 'RejectionLink';
|
||||
RejectionLink.propTypes = {
|
||||
isAvailable: PropTypes.func, // a function from context to allow link visibility control
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
payload: PropTypes.object, // Custom payload for active state
|
||||
label: PropTypes.shape({
|
||||
id: PropTypes.string
|
||||
}).isRequired
|
||||
isAvailable: PropTypes.func, // a function from context to allow link visibility control
|
||||
// eslint-disable-next-line react/forbid-prop-types
|
||||
payload: PropTypes.object, // Custom payload for active state
|
||||
label: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
RejectionLink.contextTypes = {
|
||||
reject: PropTypes.func.isRequired,
|
||||
user: userShape
|
||||
reject: PropTypes.func.isRequired,
|
||||
user: userShape,
|
||||
};
|
||||
|
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"title": "User Agreement",
|
||||
"accept": "Accept",
|
||||
"declineAndLogout": "Decline and logout",
|
||||
"description1": "We have updated our {link}.",
|
||||
"termsOfService": "terms of service",
|
||||
"description2": "In order to continue using {name} service, you need to accept them."
|
||||
"title": "User Agreement",
|
||||
"accept": "Accept",
|
||||
"declineAndLogout": "Decline and logout",
|
||||
"description1": "We have updated our {link}.",
|
||||
"termsOfService": "terms of service",
|
||||
"description2": "In order to continue using {name} service, you need to accept them."
|
||||
}
|
||||
|
@@ -4,14 +4,14 @@ import Body from './AcceptRulesBody';
|
||||
import messages from './AcceptRules.intl.json';
|
||||
|
||||
export default factory({
|
||||
title: messages.title,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'darkBlue',
|
||||
autoFocus: true,
|
||||
label: messages.accept
|
||||
},
|
||||
links: {
|
||||
label: messages.declineAndLogout
|
||||
}
|
||||
title: messages.title,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'darkBlue',
|
||||
autoFocus: true,
|
||||
label: messages.accept,
|
||||
},
|
||||
links: {
|
||||
label: messages.declineAndLogout,
|
||||
},
|
||||
});
|
||||
|
@@ -11,32 +11,38 @@ import styles from './acceptRules.scss';
|
||||
import messages from './AcceptRules.intl.json';
|
||||
|
||||
export default class AcceptRulesBody extends BaseAuthBody {
|
||||
static displayName = 'AcceptRulesBody';
|
||||
static panelId = 'acceptRules';
|
||||
static displayName = 'AcceptRulesBody';
|
||||
static panelId = 'acceptRules';
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<div className={styles.security}>
|
||||
<span className={icons.lock} />
|
||||
</div>
|
||||
<div className={styles.security}>
|
||||
<span className={icons.lock} />
|
||||
</div>
|
||||
|
||||
<p className={styles.descriptionText}>
|
||||
<Message {...messages.description1} values={{
|
||||
link: (
|
||||
<Link to="/rules" target="_blank">
|
||||
<Message {...messages.termsOfService} />
|
||||
</Link>
|
||||
)
|
||||
}} />
|
||||
<br />
|
||||
<Message {...messages.description2} values={{
|
||||
name: <Message {...appInfo.appName} />
|
||||
}} />
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<p className={styles.descriptionText}>
|
||||
<Message
|
||||
{...messages.description1}
|
||||
values={{
|
||||
link: (
|
||||
<Link to="/rules" target="_blank">
|
||||
<Message {...messages.termsOfService} />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<br />
|
||||
<Message
|
||||
{...messages.description2}
|
||||
values={{
|
||||
name: <Message {...appInfo.appName} />,
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,16 +1,16 @@
|
||||
@import '~components/ui/colors.scss';
|
||||
|
||||
.descriptionText {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
padding-bottom: 8px;
|
||||
color: #aaa;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
padding-bottom: 8px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
// TODO: вынести иконки такого типа в какую-то внешнюю структуру?
|
||||
.security {
|
||||
color: #fff;
|
||||
font-size: 90px;
|
||||
line-height: 1;
|
||||
margin-bottom: 15px;
|
||||
color: #fff;
|
||||
font-size: 90px;
|
||||
line-height: 1;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
@@ -4,187 +4,191 @@ import expect from 'test/unexpected';
|
||||
import request from 'services/request';
|
||||
|
||||
import {
|
||||
setLoadingState,
|
||||
oAuthValidate,
|
||||
oAuthComplete,
|
||||
setClient,
|
||||
setOAuthRequest,
|
||||
setScopes,
|
||||
setOAuthCode,
|
||||
requirePermissionsAccept,
|
||||
login,
|
||||
setLogin
|
||||
setLoadingState,
|
||||
oAuthValidate,
|
||||
oAuthComplete,
|
||||
setClient,
|
||||
setOAuthRequest,
|
||||
setScopes,
|
||||
setOAuthCode,
|
||||
requirePermissionsAccept,
|
||||
login,
|
||||
setLogin,
|
||||
} from 'components/auth/actions';
|
||||
|
||||
const oauthData = {
|
||||
clientId: '',
|
||||
redirectUrl: '',
|
||||
responseType: '',
|
||||
scope: '',
|
||||
state: ''
|
||||
clientId: '',
|
||||
redirectUrl: '',
|
||||
responseType: '',
|
||||
scope: '',
|
||||
state: '',
|
||||
};
|
||||
|
||||
describe('components/auth/actions', () => {
|
||||
const dispatch = sinon.stub().named('store.dispatch');
|
||||
const getState = sinon.stub().named('store.getState');
|
||||
const dispatch = sinon.stub().named('store.dispatch');
|
||||
const getState = sinon.stub().named('store.getState');
|
||||
|
||||
function callThunk(fn, ...args) {
|
||||
const thunk = fn(...args);
|
||||
function callThunk(fn, ...args) {
|
||||
const thunk = fn(...args);
|
||||
|
||||
return thunk(dispatch, getState);
|
||||
}
|
||||
return thunk(dispatch, getState);
|
||||
}
|
||||
|
||||
function expectDispatchCalls(calls) {
|
||||
expect(dispatch, 'to have calls satisfying', [
|
||||
[setLoadingState(true)]
|
||||
].concat(calls).concat([
|
||||
[setLoadingState(false)]
|
||||
]));
|
||||
}
|
||||
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.restore();
|
||||
request.post.restore();
|
||||
});
|
||||
|
||||
describe('#oAuthValidate()', () => {
|
||||
let resp;
|
||||
|
||||
beforeEach(() => {
|
||||
dispatch.reset();
|
||||
getState.reset();
|
||||
getState.returns({});
|
||||
sinon.stub(request, 'get').named('request.get');
|
||||
sinon.stub(request, 'post').named('request.post');
|
||||
resp = {
|
||||
client: { id: 123 },
|
||||
oAuth: { state: 123 },
|
||||
session: {
|
||||
scopes: ['scopes'],
|
||||
},
|
||||
};
|
||||
|
||||
request.get.returns(Promise.resolve(resp));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
request.get.restore();
|
||||
request.post.restore();
|
||||
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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe('#oAuthValidate()', () => {
|
||||
let resp;
|
||||
it('should post to api/oauth2/complete', () => {
|
||||
request.post.returns(
|
||||
Promise.resolve({
|
||||
redirectUri: '',
|
||||
}),
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
resp = {
|
||||
client: {id: 123},
|
||||
oAuth: {state: 123},
|
||||
session: {
|
||||
scopes: ['scopes']
|
||||
}
|
||||
};
|
||||
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=',
|
||||
{},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
request.get.returns(Promise.resolve(resp));
|
||||
it('should dispatch setOAuthCode for static_page redirect', () => {
|
||||
const resp = {
|
||||
success: true,
|
||||
redirectUri: 'static_page?code=123&state=',
|
||||
};
|
||||
|
||||
request.post.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', () => {
|
||||
const resp = {
|
||||
statusCode: 401,
|
||||
error: 'access_denied',
|
||||
redirectUri: 'redirectUri',
|
||||
};
|
||||
|
||||
request.post.returns(Promise.reject(resp));
|
||||
|
||||
return callThunk(oAuthComplete).then(resp => {
|
||||
expect(resp, 'to equal', {
|
||||
success: false,
|
||||
redirectUri: 'redirectUri',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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 requirePermissionsAccept if accept_required', () => {
|
||||
const resp = {
|
||||
statusCode: 401,
|
||||
error: 'accept_required',
|
||||
};
|
||||
|
||||
request.post.returns(Promise.reject(resp));
|
||||
|
||||
return callThunk(oAuthComplete).catch(resp => {
|
||||
expect(resp.acceptRequired, 'to be true');
|
||||
expectDispatchCalls([[requirePermissionsAccept()]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#login()', () => {
|
||||
describe('when correct login was entered', () => {
|
||||
beforeEach(() => {
|
||||
request.post.returns(
|
||||
Promise.reject({
|
||||
errors: {
|
||||
password: 'error.password_required',
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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.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.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', () => {
|
||||
const resp = {
|
||||
statusCode: 401,
|
||||
error: 'access_denied',
|
||||
redirectUri: 'redirectUri'
|
||||
};
|
||||
|
||||
request.post.returns(Promise.reject(resp));
|
||||
|
||||
return callThunk(oAuthComplete).then((resp) => {
|
||||
expect(resp, 'to equal', {
|
||||
success: false,
|
||||
redirectUri: 'redirectUri'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch requirePermissionsAccept if accept_required', () => {
|
||||
const resp = {
|
||||
statusCode: 401,
|
||||
error: 'accept_required'
|
||||
};
|
||||
|
||||
request.post.returns(Promise.reject(resp));
|
||||
|
||||
return callThunk(oAuthComplete).catch((resp) => {
|
||||
expect(resp.acceptRequired, 'to be true');
|
||||
expectDispatchCalls([
|
||||
[requirePermissionsAccept()]
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#login()', () => {
|
||||
describe('when correct login was entered', () => {
|
||||
beforeEach(() => {
|
||||
request.post.returns(Promise.reject({
|
||||
errors: {
|
||||
password: 'error.password_required'
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
it('should set login', () =>
|
||||
callThunk(login, {login: 'foo'}).then(() => {
|
||||
expectDispatchCalls([
|
||||
[setLogin('foo')]
|
||||
]);
|
||||
})
|
||||
);
|
||||
});
|
||||
it('should set login', () =>
|
||||
callThunk(login, { login: 'foo' }).then(() => {
|
||||
expectDispatchCalls([[setLogin('foo')]]);
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,8 +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"
|
||||
"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"
|
||||
}
|
||||
|
@@ -4,13 +4,13 @@ import messages from './Activation.intl.json';
|
||||
import Body from './ActivationBody';
|
||||
|
||||
export default factory({
|
||||
title: messages.accountActivationTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'blue',
|
||||
label: messages.confirmEmail
|
||||
},
|
||||
links: {
|
||||
label: messages.didNotReceivedEmail
|
||||
}
|
||||
title: messages.accountActivationTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'blue',
|
||||
label: messages.confirmEmail,
|
||||
},
|
||||
links: {
|
||||
label: messages.didNotReceivedEmail,
|
||||
},
|
||||
});
|
||||
|
@@ -10,55 +10,57 @@ import styles from './activation.scss';
|
||||
import messages from './Activation.intl.json';
|
||||
|
||||
export default class ActivationBody extends BaseAuthBody {
|
||||
static displayName = 'ActivationBody';
|
||||
static panelId = 'activation';
|
||||
static displayName = 'ActivationBody';
|
||||
static panelId = 'activation';
|
||||
|
||||
static propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
key: PropTypes.string
|
||||
})
|
||||
})
|
||||
};
|
||||
static propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
key: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
autoFocusField = this.props.match.params && this.props.match.params.key ? null : 'key';
|
||||
autoFocusField =
|
||||
this.props.match.params && this.props.match.params.key ? null : 'key';
|
||||
|
||||
render() {
|
||||
const {key} = this.props.match.params;
|
||||
const email = this.context.user.email;
|
||||
render() {
|
||||
const { key } = this.props.match.params;
|
||||
const { email } = this.context.user;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<div className={styles.description}>
|
||||
<div className={styles.descriptionImage} />
|
||||
<div className={styles.description}>
|
||||
<div className={styles.descriptionImage} />
|
||||
|
||||
<div className={styles.descriptionText}>
|
||||
{email
|
||||
? (
|
||||
<Message {...messages.activationMailWasSent} values={{
|
||||
email: (<b>{email}</b>)
|
||||
}} />
|
||||
)
|
||||
: (
|
||||
<Message {...messages.activationMailWasSentNoEmail} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<Input {...this.bindField('key')}
|
||||
color="blue"
|
||||
center
|
||||
required
|
||||
value={key}
|
||||
readOnly={!!key}
|
||||
autoComplete="off"
|
||||
placeholder={messages.enterTheCode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div className={styles.descriptionText}>
|
||||
{email ? (
|
||||
<Message
|
||||
{...messages.activationMailWasSent}
|
||||
values={{
|
||||
email: <b>{email}</b>,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Message {...messages.activationMailWasSentNoEmail} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...this.bindField('key')}
|
||||
color="blue"
|
||||
center
|
||||
required
|
||||
value={key}
|
||||
readOnly={!!key}
|
||||
autoComplete="off"
|
||||
placeholder={messages.enterTheCode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -5,15 +5,15 @@
|
||||
}
|
||||
|
||||
.descriptionImage {
|
||||
composes: envelope from '~components/ui/icons.scss';
|
||||
composes: envelope from '~components/ui/icons.scss';
|
||||
|
||||
font-size: 100px;
|
||||
color: $blue;
|
||||
font-size: 100px;
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
.descriptionText {
|
||||
font-family: $font-family-title;
|
||||
margin: 5px 0 19px;
|
||||
line-height: 1.4;
|
||||
font-size: 16px;
|
||||
font-family: $font-family-title;
|
||||
margin: 5px 0 19px;
|
||||
line-height: 1.4;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"appName": "Ely Accounts",
|
||||
"goToAuth": "Go to auth",
|
||||
"appDescription": "You are on the Ely.by authorization service, that allows you to safely perform any operations on your account. This single entry point for websites and desktop software, including game launchers.",
|
||||
"useItYourself": "Visit our {link}, to learn how to use this service in you projects.",
|
||||
"documentation": "documentation"
|
||||
"appName": "Ely Accounts",
|
||||
"goToAuth": "Go to auth",
|
||||
"appDescription": "You are on the Ely.by authorization service, that allows you to safely perform any operations on your account. This single entry point for websites and desktop software, including game launchers.",
|
||||
"useItYourself": "Visit our {link}, to learn how to use this service in you projects.",
|
||||
"documentation": "documentation"
|
||||
}
|
||||
|
@@ -10,52 +10,51 @@ import styles from './appInfo.scss';
|
||||
import messages from './AppInfo.intl.json';
|
||||
|
||||
export default class AppInfo extends Component<{
|
||||
name?: string,
|
||||
description?: string,
|
||||
onGoToAuth: () => void
|
||||
name?: string,
|
||||
description?: string,
|
||||
onGoToAuth: () => void,
|
||||
}> {
|
||||
render() {
|
||||
const { name, description, onGoToAuth } = this.props;
|
||||
render() {
|
||||
const { name, description, onGoToAuth } = this.props;
|
||||
|
||||
return (
|
||||
<div className={styles.appInfo}>
|
||||
<div className={styles.logoContainer}>
|
||||
<h2 className={styles.logo}>
|
||||
{name ? name : (
|
||||
<Message {...messages.appName} />
|
||||
)}
|
||||
</h2>
|
||||
</div>
|
||||
<div className={styles.descriptionContainer}>
|
||||
{description ? (
|
||||
<p className={styles.description}>
|
||||
{description}
|
||||
</p>
|
||||
) : (
|
||||
<div>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.appDescription} />
|
||||
</p>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.useItYourself} values={{
|
||||
link: (
|
||||
<a href="http://docs.ely.by/oauth.html">
|
||||
<Message {...messages.documentation} />
|
||||
</a>
|
||||
)
|
||||
}} />
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.goToAuth}>
|
||||
<Button onClick={onGoToAuth} label={messages.goToAuth} />
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<FooterMenu />
|
||||
</div>
|
||||
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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -2,71 +2,71 @@
|
||||
@import '~components/ui/fonts.scss';
|
||||
|
||||
.appInfo {
|
||||
max-width: 270px;
|
||||
margin: 0 auto;
|
||||
padding: 55px 25px;
|
||||
max-width: 270px;
|
||||
margin: 0 auto;
|
||||
padding: 55px 25px;
|
||||
}
|
||||
|
||||
.logoContainer {
|
||||
position: relative;
|
||||
padding: 15px 0;
|
||||
position: relative;
|
||||
padding: 15px 0;
|
||||
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
&:after {
|
||||
content: '';
|
||||
display: block;
|
||||
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
width: 40px;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 3px;
|
||||
width: 40px;
|
||||
|
||||
background: $green;
|
||||
}
|
||||
background: $green;
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: $font-family-title;
|
||||
color: #fff;
|
||||
font-size: 36px;
|
||||
font-family: $font-family-title;
|
||||
color: #fff;
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.descriptionContainer {
|
||||
margin: 20px 0;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
$font-color: #ccc;
|
||||
font-family: $font-family-base;
|
||||
color: $font-color;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
margin-top: 7px;
|
||||
$font-color: #ccc;
|
||||
font-family: $font-family-base;
|
||||
color: $font-color;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
margin-top: 7px;
|
||||
|
||||
a {
|
||||
color: lighten($font-color, 10%);
|
||||
border-bottom-color: #666;
|
||||
a {
|
||||
color: lighten($font-color, 10%);
|
||||
border-bottom-color: #666;
|
||||
|
||||
&:hover {
|
||||
color: $font-color;
|
||||
}
|
||||
&:hover {
|
||||
color: $font-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.goToAuth {
|
||||
}
|
||||
|
||||
@media (min-width: 720px) {
|
||||
.goToAuth {
|
||||
display: none;
|
||||
}
|
||||
.goToAuth {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
text-align: center;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
@@ -1,3 +1,3 @@
|
||||
.checkboxInput {
|
||||
margin-top: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
@@ -6,34 +6,40 @@ import { PanelBodyHeader } from 'components/ui/Panel';
|
||||
|
||||
let autoHideTimer;
|
||||
function resetTimer() {
|
||||
if (autoHideTimer) {
|
||||
clearTimeout(autoHideTimer);
|
||||
autoHideTimer = null;
|
||||
}
|
||||
if (autoHideTimer) {
|
||||
clearTimeout(autoHideTimer);
|
||||
autoHideTimer = null;
|
||||
}
|
||||
}
|
||||
export default function AuthError({error, onClose = function() {}}) {
|
||||
resetTimer();
|
||||
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
|
||||
}
|
||||
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>
|
||||
);
|
||||
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
|
||||
error: PropTypes.oneOfType([
|
||||
PropTypes.string,
|
||||
PropTypes.shape({
|
||||
type: PropTypes.string,
|
||||
payload: PropTypes.object,
|
||||
}),
|
||||
]).isRequired,
|
||||
onClose: PropTypes.func,
|
||||
};
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"chooseAccountTitle": "Choose an account",
|
||||
"addAccount": "Log into another account",
|
||||
"logoutAll": "Log out from all accounts",
|
||||
"pleaseChooseAccount": "Please select an account you're willing to use",
|
||||
"pleaseChooseAccountForApp": "Please select an account that you want to use to authorize {appName}"
|
||||
"chooseAccountTitle": "Choose an account",
|
||||
"addAccount": "Log into another account",
|
||||
"logoutAll": "Log out from all accounts",
|
||||
"pleaseChooseAccount": "Please select an account you're willing to use",
|
||||
"pleaseChooseAccountForApp": "Please select an account that you want to use to authorize {appName}"
|
||||
}
|
||||
|
@@ -3,14 +3,14 @@ import messages from './ChooseAccount.intl.json';
|
||||
import Body from './ChooseAccountBody';
|
||||
|
||||
export default factory({
|
||||
title: messages.chooseAccountTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
label: messages.addAccount
|
||||
title: messages.chooseAccountTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
label: messages.addAccount,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
label: messages.logoutAll,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
label: messages.logoutAll
|
||||
}
|
||||
]
|
||||
],
|
||||
});
|
||||
|
@@ -9,41 +9,44 @@ import styles from './chooseAccount.scss';
|
||||
import messages from './ChooseAccount.intl.json';
|
||||
|
||||
export default class ChooseAccountBody extends BaseAuthBody {
|
||||
static displayName = 'ChooseAccountBody';
|
||||
static panelId = 'chooseAccount';
|
||||
static displayName = 'ChooseAccountBody';
|
||||
static panelId = 'chooseAccount';
|
||||
|
||||
render() {
|
||||
const {client} = this.context.auth;
|
||||
render() {
|
||||
const { client } = this.context.auth;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<div className={styles.description}>
|
||||
{client ? (
|
||||
<Message {...messages.pleaseChooseAccountForApp} values={{
|
||||
appName: <span className={styles.appName}>{client.name}</span>,
|
||||
}} />
|
||||
) : (
|
||||
<div className={styles.description}>
|
||||
<Message {...messages.pleaseChooseAccount} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.accountSwitcherContainer}>
|
||||
<AccountSwitcher
|
||||
allowAdd={false}
|
||||
allowLogout={false}
|
||||
highlightActiveAccount={false}
|
||||
onSwitch={this.onSwitch}
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
onSwitch = (account) => {
|
||||
this.context.resolve(account);
|
||||
};
|
||||
<div className={styles.accountSwitcherContainer}>
|
||||
<AccountSwitcher
|
||||
allowAdd={false}
|
||||
allowLogout={false}
|
||||
highlightActiveAccount={false}
|
||||
onSwitch={this.onSwitch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onSwitch = account => {
|
||||
this.context.resolve(account);
|
||||
};
|
||||
}
|
||||
|
@@ -2,17 +2,17 @@
|
||||
@import '~components/ui/fonts.scss';
|
||||
|
||||
.accountSwitcherContainer {
|
||||
margin-left: -$bodyLeftRightPadding;
|
||||
margin-right: -$bodyLeftRightPadding;
|
||||
margin-left: -$bodyLeftRightPadding;
|
||||
margin-right: -$bodyLeftRightPadding;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-family: $font-family-title;
|
||||
margin: 5px 0 19px;
|
||||
line-height: 1.4;
|
||||
font-size: 16px;
|
||||
font-family: $font-family-title;
|
||||
margin: 5px 0 19px;
|
||||
line-height: 1.4;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.appName {
|
||||
color: #fff;
|
||||
color: #fff;
|
||||
}
|
||||
|
@@ -9,21 +9,25 @@ import AuthTitle from 'components/auth/AuthTitle';
|
||||
* @param {string|object} options.title - panel title
|
||||
* @param {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
|
||||
* @param {Array|object|null} options.links - link config or an array of link configs
|
||||
*
|
||||
* @return {object} - structure, required for auth panel to work
|
||||
* @returns {object} - structure, required for auth panel to work
|
||||
*/
|
||||
export default function(options) {
|
||||
return () => ({
|
||||
Title: () => <AuthTitle title={options.title} />,
|
||||
Body: options.body,
|
||||
Footer: () => <Button type="submit" {...options.footer} />,
|
||||
Links: () => options.links ? (
|
||||
<span>
|
||||
{[].concat(options.links).map((link, index) => (
|
||||
[index ? ' | ' : '', <RejectionLink {...link} key={index} />]
|
||||
))}
|
||||
</span>
|
||||
) : null
|
||||
});
|
||||
return () => ({
|
||||
Title: () => <AuthTitle title={options.title} />,
|
||||
Body: options.body,
|
||||
Footer: () => <Button type="submit" {...options.footer} />,
|
||||
Links: () =>
|
||||
options.links ? (
|
||||
<span>
|
||||
{[]
|
||||
.concat(options.links)
|
||||
.map((link, index) => [
|
||||
index ? ' | ' : '',
|
||||
<RejectionLink {...link} key={index} />,
|
||||
])}
|
||||
</span>
|
||||
) : null,
|
||||
});
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"authForAppSuccessful": "Authorization for {appName} was successfully completed",
|
||||
"authForAppFailed": "Authorization for {appName} was failed",
|
||||
"waitAppReaction": "Please, wait till your application response",
|
||||
"passCodeToApp": "To complete authorization process, please, provide the following code to {appName}",
|
||||
"copy": "Copy"
|
||||
"authForAppSuccessful": "Authorization for {appName} was successfully completed",
|
||||
"authForAppFailed": "Authorization for {appName} was failed",
|
||||
"waitAppReaction": "Please, wait till your application response",
|
||||
"passCodeToApp": "To complete authorization process, please, provide the following code to {appName}",
|
||||
"copy": "Copy"
|
||||
}
|
||||
|
@@ -12,87 +12,91 @@ import messages from './Finish.intl.json';
|
||||
import styles from './finish.scss';
|
||||
|
||||
class Finish extends Component {
|
||||
static displayName = 'Finish';
|
||||
static displayName = 'Finish';
|
||||
|
||||
static propTypes = {
|
||||
appName: PropTypes.string.isRequired,
|
||||
code: PropTypes.string.isRequired,
|
||||
state: PropTypes.string.isRequired,
|
||||
displayCode: PropTypes.bool,
|
||||
success: PropTypes.bool
|
||||
};
|
||||
static propTypes = {
|
||||
appName: PropTypes.string.isRequired,
|
||||
code: PropTypes.string.isRequired,
|
||||
state: PropTypes.string.isRequired,
|
||||
displayCode: PropTypes.bool,
|
||||
success: PropTypes.bool,
|
||||
};
|
||||
|
||||
render() {
|
||||
const {appName, code, state, displayCode, success} = this.props;
|
||||
const authData = JSON.stringify({
|
||||
auth_code: code, // eslint-disable-line
|
||||
state
|
||||
});
|
||||
render() {
|
||||
const { appName, code, state, displayCode, success } = this.props;
|
||||
const authData = JSON.stringify({
|
||||
auth_code: code, // eslint-disable-line
|
||||
state,
|
||||
});
|
||||
|
||||
history.pushState(null, null, `#${authData}`);
|
||||
history.pushState(null, null, `#${authData}`);
|
||||
|
||||
return (
|
||||
<div className={styles.finishPage}>
|
||||
<Helmet title={authData} />
|
||||
return (
|
||||
<div className={styles.finishPage}>
|
||||
<Helmet title={authData} />
|
||||
|
||||
{success ? (
|
||||
<div>
|
||||
<div className={styles.successBackground} />
|
||||
<div className={styles.greenTitle}>
|
||||
<Message {...messages.authForAppSuccessful} values={{
|
||||
appName: (<span className={styles.appName}>{appName}</span>)
|
||||
}} />
|
||||
</div>
|
||||
{displayCode ? (
|
||||
<div>
|
||||
<div className={styles.description}>
|
||||
<Message {...messages.passCodeToApp} values={{appName}} />
|
||||
</div>
|
||||
<div className={styles.codeContainer}>
|
||||
<div className={styles.code}>
|
||||
{code}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
color="green"
|
||||
small
|
||||
label={messages.copy}
|
||||
onClick={this.onCopyClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.description}>
|
||||
<Message {...messages.waitAppReaction} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className={styles.failBackground} />
|
||||
<div className={styles.redTitle}>
|
||||
<Message {...messages.authForAppFailed} values={{
|
||||
appName: (<span className={styles.appName}>{appName}</span>)
|
||||
}} />
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
<Message {...messages.waitAppReaction} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{success ? (
|
||||
<div>
|
||||
<div className={styles.successBackground} />
|
||||
<div className={styles.greenTitle}>
|
||||
<Message
|
||||
{...messages.authForAppSuccessful}
|
||||
values={{
|
||||
appName: <span className={styles.appName}>{appName}</span>,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{displayCode ? (
|
||||
<div>
|
||||
<div className={styles.description}>
|
||||
<Message {...messages.passCodeToApp} values={{ appName }} />
|
||||
</div>
|
||||
<div className={styles.codeContainer}>
|
||||
<div className={styles.code}>{code}</div>
|
||||
</div>
|
||||
<Button
|
||||
color="green"
|
||||
small
|
||||
label={messages.copy}
|
||||
onClick={this.onCopyClick}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.description}>
|
||||
<Message {...messages.waitAppReaction} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className={styles.failBackground} />
|
||||
<div className={styles.redTitle}>
|
||||
<Message
|
||||
{...messages.authForAppFailed}
|
||||
values={{
|
||||
appName: <span className={styles.appName}>{appName}</span>,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
<Message {...messages.waitAppReaction} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onCopyClick = (event) => {
|
||||
event.preventDefault();
|
||||
copy(this.props.code);
|
||||
};
|
||||
onCopyClick = event => {
|
||||
event.preventDefault();
|
||||
copy(this.props.code);
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(({auth}) => ({
|
||||
appName: auth.client.name,
|
||||
code: auth.oauth.code,
|
||||
displayCode: auth.oauth.displayCode,
|
||||
state: auth.oauth.state,
|
||||
success: auth.oauth.success
|
||||
export default connect(({ auth }) => ({
|
||||
appName: auth.client.name,
|
||||
code: auth.oauth.code,
|
||||
displayCode: auth.oauth.displayCode,
|
||||
state: auth.oauth.state,
|
||||
success: auth.oauth.success,
|
||||
}))(Finish);
|
||||
|
@@ -2,75 +2,75 @@
|
||||
@import '~components/ui/fonts.scss';
|
||||
|
||||
.finishPage {
|
||||
font-family: $font-family-title;
|
||||
position: relative;
|
||||
max-width: 515px;
|
||||
padding-top: 40px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
font-family: $font-family-title;
|
||||
position: relative;
|
||||
max-width: 515px;
|
||||
padding-top: 40px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.iconBackground {
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 200px;
|
||||
color: #e0d9cf;
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
top: -15px;
|
||||
transform: translateX(-50%);
|
||||
font-size: 200px;
|
||||
color: #e0d9cf;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.successBackground {
|
||||
composes: checkmark from '~components/ui/icons.scss';
|
||||
@extend .iconBackground;
|
||||
composes: checkmark from '~components/ui/icons.scss';
|
||||
@extend .iconBackground;
|
||||
}
|
||||
|
||||
.failBackground {
|
||||
composes: close from '~components/ui/icons.scss';
|
||||
@extend .iconBackground;
|
||||
composes: close from '~components/ui/icons.scss';
|
||||
@extend .iconBackground;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 22px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.greenTitle {
|
||||
composes: title;
|
||||
composes: title;
|
||||
|
||||
color: $green;
|
||||
color: $green;
|
||||
|
||||
.appName {
|
||||
color: darker($green);
|
||||
}
|
||||
.appName {
|
||||
color: darker($green);
|
||||
}
|
||||
}
|
||||
|
||||
.redTitle {
|
||||
composes: title;
|
||||
composes: title;
|
||||
|
||||
color: $red;
|
||||
color: $red;
|
||||
|
||||
.appName {
|
||||
color: darker($red);
|
||||
}
|
||||
.appName {
|
||||
color: darker($red);
|
||||
}
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 18px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.codeContainer {
|
||||
margin-bottom: 5px;
|
||||
margin-top: 35px;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 35px;
|
||||
}
|
||||
|
||||
.code {
|
||||
$border: 5px solid darker($green);
|
||||
$border: 5px solid darker($green);
|
||||
|
||||
display: inline-block;
|
||||
border-right: $border;
|
||||
border-left: $border;
|
||||
padding: 5px 10px;
|
||||
word-break: break-all;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
border-right: $border;
|
||||
border-left: $border;
|
||||
padding: 5px 10px;
|
||||
word-break: break-all;
|
||||
text-align: center;
|
||||
}
|
||||
|
@@ -1,7 +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"
|
||||
"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"
|
||||
}
|
||||
|
@@ -4,14 +4,14 @@ import messages from './ForgotPassword.intl.json';
|
||||
import Body from './ForgotPasswordBody';
|
||||
|
||||
export default factory({
|
||||
title: messages.title,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'lightViolet',
|
||||
autoFocus: true,
|
||||
label: messages.sendMail
|
||||
},
|
||||
links: {
|
||||
label: messages.alreadyHaveCode
|
||||
}
|
||||
title: messages.title,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'lightViolet',
|
||||
autoFocus: true,
|
||||
label: messages.sendMail,
|
||||
},
|
||||
links: {
|
||||
label: messages.alreadyHaveCode,
|
||||
},
|
||||
});
|
||||
|
@@ -11,79 +11,79 @@ import styles from './forgotPassword.scss';
|
||||
import messages from './ForgotPassword.intl.json';
|
||||
|
||||
export default class ForgotPasswordBody extends BaseAuthBody {
|
||||
static displayName = 'ForgotPasswordBody';
|
||||
static panelId = 'forgotPassword';
|
||||
static hasGoBack = true;
|
||||
static displayName = 'ForgotPasswordBody';
|
||||
static panelId = 'forgotPassword';
|
||||
static hasGoBack = true;
|
||||
|
||||
state = {
|
||||
isLoginEdit: !this.getLogin()
|
||||
};
|
||||
state = {
|
||||
isLoginEdit: !this.getLogin(),
|
||||
};
|
||||
|
||||
autoFocusField = this.state.isLoginEdit ? 'login' : null;
|
||||
autoFocusField = this.state.isLoginEdit ? 'login' : null;
|
||||
|
||||
render() {
|
||||
const login = this.getLogin();
|
||||
const isLoginEditShown = this.state.isLoginEdit;
|
||||
render() {
|
||||
const login = this.getLogin();
|
||||
const isLoginEditShown = this.state.isLoginEdit;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<PanelIcon icon="lock" />
|
||||
<PanelIcon icon="lock" />
|
||||
|
||||
{isLoginEditShown ? (
|
||||
<div>
|
||||
<p className={styles.descriptionText}>
|
||||
<Message {...messages.specifyEmail} />
|
||||
</p>
|
||||
<Input {...this.bindField('login')}
|
||||
icon="envelope"
|
||||
color="lightViolet"
|
||||
required
|
||||
placeholder={messages.accountEmail}
|
||||
defaultValue={login}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className={styles.login}>
|
||||
{login}
|
||||
<span className={styles.editLogin} onClick={this.onClickEdit} />
|
||||
</div>
|
||||
<p className={styles.descriptionText}>
|
||||
<Message {...messages.pleasePressButton} />
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Captcha {...this.bindField('captcha')} delay={600} />
|
||||
{isLoginEditShown ? (
|
||||
<div>
|
||||
<p className={styles.descriptionText}>
|
||||
<Message {...messages.specifyEmail} />
|
||||
</p>
|
||||
<Input
|
||||
{...this.bindField('login')}
|
||||
icon="envelope"
|
||||
color="lightViolet"
|
||||
required
|
||||
placeholder={messages.accountEmail}
|
||||
defaultValue={login}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className={styles.login}>
|
||||
{login}
|
||||
<span className={styles.editLogin} onClick={this.onClickEdit} />
|
||||
</div>
|
||||
);
|
||||
<p className={styles.descriptionText}>
|
||||
<Message {...messages.pleasePressButton} />
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Captcha {...this.bindField('captcha')} delay={600} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
serialize() {
|
||||
const data = super.serialize();
|
||||
|
||||
if (!data.login) {
|
||||
data.login = this.getLogin();
|
||||
}
|
||||
|
||||
serialize() {
|
||||
const data = super.serialize();
|
||||
return data;
|
||||
}
|
||||
|
||||
if (!data.login) {
|
||||
data.login = this.getLogin();
|
||||
}
|
||||
getLogin() {
|
||||
const login = getLogin(this.context);
|
||||
const { user } = this.context;
|
||||
|
||||
return data;
|
||||
}
|
||||
return login || user.username || user.email || '';
|
||||
}
|
||||
|
||||
getLogin() {
|
||||
const login = getLogin(this.context);
|
||||
const { user } = this.context;
|
||||
onClickEdit = () => {
|
||||
this.setState({
|
||||
isLoginEdit: true,
|
||||
});
|
||||
|
||||
return login || user.username || user.email || '';
|
||||
}
|
||||
|
||||
onClickEdit = () => {
|
||||
this.setState({
|
||||
isLoginEdit: true
|
||||
});
|
||||
|
||||
this.context.requestRedraw()
|
||||
.then(() => this.form.focus('login'));
|
||||
};
|
||||
this.context.requestRedraw().then(() => this.form.focus('login'));
|
||||
};
|
||||
}
|
||||
|
@@ -1,31 +1,31 @@
|
||||
@import '~components/ui/colors.scss';
|
||||
|
||||
.descriptionText {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
padding-bottom: 8px;
|
||||
color: #aaa;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
padding-bottom: 8px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.login {
|
||||
composes: email from '~components/auth/password/password.scss';
|
||||
composes: email from '~components/auth/password/password.scss';
|
||||
}
|
||||
|
||||
.editLogin {
|
||||
composes: pencil from '~components/ui/icons.scss';
|
||||
composes: pencil from '~components/ui/icons.scss';
|
||||
|
||||
position: relative;
|
||||
bottom: 1px;
|
||||
padding-left: 3px;
|
||||
position: relative;
|
||||
bottom: 1px;
|
||||
padding-left: 3px;
|
||||
|
||||
color: #666666;
|
||||
font-size: 10px;
|
||||
color: #666666;
|
||||
font-size: 10px;
|
||||
|
||||
transition: color .3s;
|
||||
transition: color 0.3s;
|
||||
|
||||
cursor: pointer;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
&:hover {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
@@ -1,9 +1,9 @@
|
||||
.helpLinks {
|
||||
margin: 8px 0;
|
||||
position: relative;
|
||||
height: 20px;
|
||||
margin: 8px 0;
|
||||
position: relative;
|
||||
height: 20px;
|
||||
|
||||
color: #444;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
color: #444;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"createNewAccount": "Create new account",
|
||||
"loginTitle": "Sign in",
|
||||
"emailOrUsername": "E‑mail or username",
|
||||
"next": "Next"
|
||||
"createNewAccount": "Create new account",
|
||||
"loginTitle": "Sign in",
|
||||
"emailOrUsername": "E‑mail or username",
|
||||
"next": "Next"
|
||||
}
|
||||
|
@@ -4,15 +4,14 @@ import Body from './LoginBody';
|
||||
import messages from './Login.intl.json';
|
||||
|
||||
export default factory({
|
||||
title: messages.loginTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'green',
|
||||
label: messages.next
|
||||
},
|
||||
links: {
|
||||
isAvailable: (context) => !context.user.isGuest,
|
||||
label: messages.createNewAccount
|
||||
}
|
||||
title: messages.loginTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'green',
|
||||
label: messages.next,
|
||||
},
|
||||
links: {
|
||||
isAvailable: context => !context.user.isGuest,
|
||||
label: messages.createNewAccount,
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -5,25 +5,26 @@ import BaseAuthBody from '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;
|
||||
};
|
||||
static displayName = 'LoginBody';
|
||||
static panelId = 'login';
|
||||
static hasGoBack = state => {
|
||||
return !state.user.isGuest;
|
||||
};
|
||||
|
||||
autoFocusField = 'login';
|
||||
autoFocusField = 'login';
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<Input {...this.bindField('login')}
|
||||
icon="envelope"
|
||||
required
|
||||
placeholder={messages.emailOrUsername}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Input
|
||||
{...this.bindField('login')}
|
||||
icon="envelope"
|
||||
required
|
||||
placeholder={messages.emailOrUsername}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"enterTotp": "Enter code",
|
||||
"description": "In order to sign in this account, you need to enter a one-time password from mobile application"
|
||||
"enterTotp": "Enter code",
|
||||
"description": "In order to sign in this account, you need to enter a one-time password from mobile application"
|
||||
}
|
||||
|
@@ -6,10 +6,10 @@ import messages from './Mfa.intl.json';
|
||||
import passwordMessages from '../password/Password.intl.json';
|
||||
|
||||
export default factory({
|
||||
title: messages.enterTotp,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'green',
|
||||
label: passwordMessages.signInButton
|
||||
}
|
||||
title: messages.enterTotp,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'green',
|
||||
label: passwordMessages.signInButton,
|
||||
},
|
||||
});
|
||||
|
@@ -11,29 +11,30 @@ import styles from './mfa.scss';
|
||||
import messages from './Mfa.intl.json';
|
||||
|
||||
export default class MfaBody extends BaseAuthBody {
|
||||
static panelId = 'mfa';
|
||||
static hasGoBack = true;
|
||||
static panelId = 'mfa';
|
||||
static hasGoBack = true;
|
||||
|
||||
autoFocusField = 'totp';
|
||||
autoFocusField = 'totp';
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<PanelIcon icon="lock" />
|
||||
<PanelIcon icon="lock" />
|
||||
|
||||
<p className={styles.descriptionText}>
|
||||
<Message {...messages.description} />
|
||||
</p>
|
||||
<p className={styles.descriptionText}>
|
||||
<Message {...messages.description} />
|
||||
</p>
|
||||
|
||||
<Input {...this.bindField('totp')}
|
||||
icon="key"
|
||||
required
|
||||
placeholder={messages.enterTotp}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Input
|
||||
{...this.bindField('totp')}
|
||||
icon="key"
|
||||
required
|
||||
placeholder={messages.enterTotp}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,6 +1,6 @@
|
||||
.descriptionText {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
padding-bottom: 8px;
|
||||
color: #aaa;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
padding-bottom: 8px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"passwordTitle": "Enter password",
|
||||
"signInButton": "Sign in",
|
||||
"forgotPassword": "Forgot password",
|
||||
"accountPassword": "Account password",
|
||||
"rememberMe": "Remember me on this device"
|
||||
"passwordTitle": "Enter password",
|
||||
"signInButton": "Sign in",
|
||||
"forgotPassword": "Forgot password",
|
||||
"accountPassword": "Account password",
|
||||
"rememberMe": "Remember me on this device"
|
||||
}
|
||||
|
@@ -4,13 +4,13 @@ import Body from './PasswordBody';
|
||||
import messages from './Password.intl.json';
|
||||
|
||||
export default factory({
|
||||
title: messages.passwordTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'green',
|
||||
label: messages.signInButton
|
||||
},
|
||||
links: {
|
||||
label: messages.forgotPassword
|
||||
}
|
||||
title: messages.passwordTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'green',
|
||||
label: messages.signInButton,
|
||||
},
|
||||
links: {
|
||||
label: messages.forgotPassword,
|
||||
},
|
||||
});
|
||||
|
@@ -9,45 +9,46 @@ import styles from './password.scss';
|
||||
import messages from './Password.intl.json';
|
||||
|
||||
export default class PasswordBody extends BaseAuthBody {
|
||||
static displayName = 'PasswordBody';
|
||||
static panelId = 'password';
|
||||
static hasGoBack = true;
|
||||
static displayName = 'PasswordBody';
|
||||
static panelId = 'password';
|
||||
static hasGoBack = true;
|
||||
|
||||
autoFocusField = 'password';
|
||||
autoFocusField = 'password';
|
||||
|
||||
render() {
|
||||
const {user} = this.context;
|
||||
render() {
|
||||
const { user } = this.context;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<div className={styles.miniProfile}>
|
||||
<div className={styles.avatar}>
|
||||
{user.avatar
|
||||
? <img src={user.avatar} />
|
||||
: <span className={icons.user} />
|
||||
}
|
||||
</div>
|
||||
<div className={styles.email}>
|
||||
{user.email || user.username}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.miniProfile}>
|
||||
<div className={styles.avatar}>
|
||||
{user.avatar ? (
|
||||
<img src={user.avatar} />
|
||||
) : (
|
||||
<span className={icons.user} />
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.email}>{user.email || user.username}</div>
|
||||
</div>
|
||||
|
||||
<Input {...this.bindField('password')}
|
||||
icon="key"
|
||||
type="password"
|
||||
required
|
||||
placeholder={messages.accountPassword}
|
||||
/>
|
||||
<Input
|
||||
{...this.bindField('password')}
|
||||
icon="key"
|
||||
type="password"
|
||||
required
|
||||
placeholder={messages.accountPassword}
|
||||
/>
|
||||
|
||||
<div className={authStyles.checkboxInput}>
|
||||
<Checkbox {...this.bindField('rememberMe')}
|
||||
defaultChecked
|
||||
label={messages.rememberMe}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div className={authStyles.checkboxInput}>
|
||||
<Checkbox
|
||||
{...this.bindField('rememberMe')}
|
||||
defaultChecked
|
||||
label={messages.rememberMe}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,22 +1,22 @@
|
||||
@import '~components/ui/fonts.scss';
|
||||
|
||||
.avatar {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
font-size: 90px;
|
||||
line-height: 1;
|
||||
margin: 0 auto;
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
font-size: 90px;
|
||||
line-height: 1;
|
||||
margin: 0 auto;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.email {
|
||||
font-family: $font-family-title;
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
font-family: $font-family-title;
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
|
||||
margin-bottom: 15px;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 15px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"permissionsTitle": "Application permissions",
|
||||
"youAuthorizedAs": "You authorized as:",
|
||||
"theAppNeedsAccess1": "This application needs access",
|
||||
"theAppNeedsAccess2": "to your data",
|
||||
"decline": "Decline",
|
||||
"approve": "Approve",
|
||||
"scope_minecraft_server_session": "Authorization data for minecraft server",
|
||||
"scope_offline_access": "Access to your profile data, when you offline",
|
||||
"scope_account_info": "Access to your profile data (except E‑mail)",
|
||||
"scope_account_email": "Access to your E‑mail address"
|
||||
"permissionsTitle": "Application permissions",
|
||||
"youAuthorizedAs": "You authorized as:",
|
||||
"theAppNeedsAccess1": "This application needs access",
|
||||
"theAppNeedsAccess2": "to your data",
|
||||
"decline": "Decline",
|
||||
"approve": "Approve",
|
||||
"scope_minecraft_server_session": "Authorization data for minecraft server",
|
||||
"scope_offline_access": "Access to your profile data, when you offline",
|
||||
"scope_account_info": "Access to your profile data (except E‑mail)",
|
||||
"scope_account_email": "Access to your E‑mail address"
|
||||
}
|
||||
|
@@ -3,15 +3,14 @@ import messages from './Permissions.intl.json';
|
||||
import Body from './PermissionsBody';
|
||||
|
||||
export default factory({
|
||||
title: messages.permissionsTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'orange',
|
||||
autoFocus: true,
|
||||
label: messages.approve
|
||||
},
|
||||
links: {
|
||||
label: messages.decline
|
||||
}
|
||||
title: messages.permissionsTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'orange',
|
||||
autoFocus: true,
|
||||
label: messages.approve,
|
||||
},
|
||||
links: {
|
||||
label: messages.decline,
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -10,54 +10,58 @@ import styles from './permissions.scss';
|
||||
import messages from './Permissions.intl.json';
|
||||
|
||||
export default class PermissionsBody extends BaseAuthBody {
|
||||
static displayName = 'PermissionsBody';
|
||||
static panelId = 'permissions';
|
||||
static displayName = 'PermissionsBody';
|
||||
static panelId = 'permissions';
|
||||
|
||||
render() {
|
||||
const {user} = this.context;
|
||||
const scopes = this.context.auth.scopes;
|
||||
render() {
|
||||
const { user } = this.context;
|
||||
const { scopes } = this.context.auth;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<PanelBodyHeader>
|
||||
<div className={styles.authInfo}>
|
||||
<div className={styles.authInfoAvatar}>
|
||||
{user.avatar
|
||||
? <img src={user.avatar} />
|
||||
: <span className={icons.user} />
|
||||
}
|
||||
</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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -2,76 +2,76 @@
|
||||
@import '~components/ui/fonts.scss';
|
||||
|
||||
.authInfo {
|
||||
// Отступы сверху и снизу разные т.к. мы ужимаем высоту линии строки с логином на 2 пикселя и из-за этого теряем отступ снизу
|
||||
padding: 5px 20px 7px;
|
||||
text-align: left;
|
||||
// Отступы сверху и снизу разные т.к. мы ужимаем высоту линии строки с логином на 2 пикселя и из-за этого теряем отступ снизу
|
||||
padding: 5px 20px 7px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.authInfoAvatar {
|
||||
$size: 30px;
|
||||
$size: 30px;
|
||||
|
||||
float: left;
|
||||
height: $size;
|
||||
width: $size;
|
||||
font-size: $size;
|
||||
line-height: 1;
|
||||
margin-right: 10px;
|
||||
margin-top: 2px;
|
||||
color: #aaa;
|
||||
float: left;
|
||||
height: $size;
|
||||
width: $size;
|
||||
font-size: $size;
|
||||
line-height: 1;
|
||||
margin-right: 10px;
|
||||
margin-top: 2px;
|
||||
color: #aaa;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
img {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.authInfoTitle {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.authInfoEmail {
|
||||
font-family: $font-family-title;
|
||||
font-size: 20px;
|
||||
line-height: 16px;
|
||||
color: #fff;
|
||||
font-family: $font-family-title;
|
||||
font-size: 20px;
|
||||
line-height: 16px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.permissionsContainer {
|
||||
padding: 15px 12px;
|
||||
text-align: left;
|
||||
padding: 15px 12px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.permissionsTitle {
|
||||
font-family: $font-family-title;
|
||||
font-size: 18px;
|
||||
color: #dd8650;
|
||||
padding-bottom: 6px;
|
||||
font-family: $font-family-title;
|
||||
font-size: 18px;
|
||||
color: #dd8650;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.permissionsList {
|
||||
list-style: none;
|
||||
margin-top: 10px;
|
||||
list-style: none;
|
||||
margin-top: 10px;
|
||||
|
||||
li {
|
||||
color: #a9a9a9;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
padding-bottom: 4px;
|
||||
padding-left: 17px;
|
||||
position: relative;
|
||||
li {
|
||||
color: #a9a9a9;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
padding-bottom: 4px;
|
||||
padding-left: 17px;
|
||||
position: relative;
|
||||
|
||||
&:last-of-type {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: "• ";
|
||||
color: lighter($light_violet);
|
||||
font-size: 39px; // ~ 9px
|
||||
line-height: 9px;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: -4px;
|
||||
}
|
||||
&: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -1,12 +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"
|
||||
"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"
|
||||
}
|
||||
|
@@ -4,13 +4,13 @@ import messages from './RecoverPassword.intl.json';
|
||||
import Body from './RecoverPasswordBody';
|
||||
|
||||
export default factory({
|
||||
title: messages.title,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'lightViolet',
|
||||
label: messages.change
|
||||
},
|
||||
links: {
|
||||
label: messages.contactSupport
|
||||
}
|
||||
title: messages.title,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'lightViolet',
|
||||
label: messages.change,
|
||||
},
|
||||
links: {
|
||||
label: messages.contactSupport,
|
||||
},
|
||||
});
|
||||
|
@@ -12,70 +12,78 @@ import messages from './RecoverPassword.intl.json';
|
||||
// TODO: activation code field may be decoupled into common component and reused here and in activation panel
|
||||
|
||||
export default class RecoverPasswordBody extends BaseAuthBody {
|
||||
static displayName = 'RecoverPasswordBody';
|
||||
static panelId = 'recoverPassword';
|
||||
static hasGoBack = true;
|
||||
static displayName = 'RecoverPasswordBody';
|
||||
static panelId = 'recoverPassword';
|
||||
static hasGoBack = true;
|
||||
|
||||
static propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
key: PropTypes.string
|
||||
})
|
||||
})
|
||||
};
|
||||
static propTypes = {
|
||||
match: PropTypes.shape({
|
||||
params: PropTypes.shape({
|
||||
key: PropTypes.string,
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
autoFocusField = this.props.match.params && this.props.match.params.key ? 'newPassword' : 'key';
|
||||
autoFocusField =
|
||||
this.props.match.params && this.props.match.params.key
|
||||
? 'newPassword'
|
||||
: 'key';
|
||||
|
||||
render() {
|
||||
const {user} = this.context;
|
||||
const {key} = this.props.match.params;
|
||||
render() {
|
||||
const { user } = this.context;
|
||||
const { key } = this.props.match.params;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<p className={styles.descriptionText}>
|
||||
{user.maskedEmail ? (
|
||||
<Message {...messages.messageWasSentTo} values={{
|
||||
email: <b>{user.maskedEmail}</b>
|
||||
}} />
|
||||
) : (
|
||||
<Message {...messages.messageWasSent} />
|
||||
)}
|
||||
{' '}
|
||||
<Message {...messages.enterCodeBelow} />
|
||||
</p>
|
||||
<p className={styles.descriptionText}>
|
||||
{user.maskedEmail ? (
|
||||
<Message
|
||||
{...messages.messageWasSentTo}
|
||||
values={{
|
||||
email: <b>{user.maskedEmail}</b>,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Message {...messages.messageWasSent} />
|
||||
)}{' '}
|
||||
<Message {...messages.enterCodeBelow} />
|
||||
</p>
|
||||
|
||||
<Input {...this.bindField('key')}
|
||||
color="lightViolet"
|
||||
center
|
||||
required
|
||||
value={key}
|
||||
readOnly={!!key}
|
||||
autoComplete="off"
|
||||
placeholder={messages.enterTheCode}
|
||||
/>
|
||||
<Input
|
||||
{...this.bindField('key')}
|
||||
color="lightViolet"
|
||||
center
|
||||
required
|
||||
value={key}
|
||||
readOnly={!!key}
|
||||
autoComplete="off"
|
||||
placeholder={messages.enterTheCode}
|
||||
/>
|
||||
|
||||
<p className={styles.descriptionText}>
|
||||
<Message {...messages.enterNewPasswordBelow} />
|
||||
</p>
|
||||
<p className={styles.descriptionText}>
|
||||
<Message {...messages.enterNewPasswordBelow} />
|
||||
</p>
|
||||
|
||||
<Input {...this.bindField('newPassword')}
|
||||
icon="key"
|
||||
color="lightViolet"
|
||||
type="password"
|
||||
required
|
||||
placeholder={messages.newPassword}
|
||||
/>
|
||||
<Input
|
||||
{...this.bindField('newPassword')}
|
||||
icon="key"
|
||||
color="lightViolet"
|
||||
type="password"
|
||||
required
|
||||
placeholder={messages.newPassword}
|
||||
/>
|
||||
|
||||
<Input {...this.bindField('newRePassword')}
|
||||
icon="key"
|
||||
color="lightViolet"
|
||||
type="password"
|
||||
required
|
||||
placeholder={messages.newRePassword}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Input
|
||||
{...this.bindField('newRePassword')}
|
||||
icon="key"
|
||||
color="lightViolet"
|
||||
type="password"
|
||||
required
|
||||
placeholder={messages.newRePassword}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,8 +1,8 @@
|
||||
@import '~components/ui/colors.scss';
|
||||
|
||||
.descriptionText {
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
color: #aaa;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
margin-bottom: 8px;
|
||||
color: #aaa;
|
||||
}
|
||||
|
@@ -2,157 +2,157 @@
|
||||
import { combineReducers } from 'redux';
|
||||
|
||||
import {
|
||||
ERROR,
|
||||
SET_CLIENT,
|
||||
SET_OAUTH,
|
||||
SET_OAUTH_RESULT,
|
||||
SET_SCOPES,
|
||||
SET_LOADING_STATE,
|
||||
REQUIRE_PERMISSIONS_ACCEPT,
|
||||
SET_CREDENTIALS,
|
||||
SET_SWITCHER
|
||||
ERROR,
|
||||
SET_CLIENT,
|
||||
SET_OAUTH,
|
||||
SET_OAUTH_RESULT,
|
||||
SET_SCOPES,
|
||||
SET_LOADING_STATE,
|
||||
REQUIRE_PERMISSIONS_ACCEPT,
|
||||
SET_CREDENTIALS,
|
||||
SET_SWITCHER,
|
||||
} from './actions';
|
||||
|
||||
type Credentials = {
|
||||
login?: string,
|
||||
password?: string,
|
||||
rememberMe?: bool,
|
||||
returnUrl?: string,
|
||||
isRelogin?: bool,
|
||||
isTotpRequired?: bool
|
||||
login?: string,
|
||||
password?: string,
|
||||
rememberMe?: boolean,
|
||||
returnUrl?: string,
|
||||
isRelogin?: boolean,
|
||||
isTotpRequired?: boolean,
|
||||
};
|
||||
|
||||
export default combineReducers<_, { action: string, payload?: mixed }>({
|
||||
credentials,
|
||||
error,
|
||||
isLoading,
|
||||
isSwitcherEnabled,
|
||||
client,
|
||||
oauth,
|
||||
scopes
|
||||
credentials,
|
||||
error,
|
||||
isLoading,
|
||||
isSwitcherEnabled,
|
||||
client,
|
||||
oauth,
|
||||
scopes,
|
||||
});
|
||||
|
||||
function error(state = null, { type, payload = null, error = false }) {
|
||||
switch (type) {
|
||||
case ERROR:
|
||||
if (!error) {
|
||||
throw new Error('Expected payload with error');
|
||||
}
|
||||
switch (type) {
|
||||
case ERROR:
|
||||
if (!error) {
|
||||
throw new Error('Expected payload with error');
|
||||
}
|
||||
|
||||
return payload;
|
||||
return payload;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function credentials(
|
||||
state = {},
|
||||
{
|
||||
type,
|
||||
payload
|
||||
}: {
|
||||
type: string,
|
||||
payload: ?Credentials
|
||||
}
|
||||
state = {},
|
||||
{
|
||||
type,
|
||||
payload,
|
||||
}: {
|
||||
type: string,
|
||||
payload: ?Credentials,
|
||||
},
|
||||
) {
|
||||
if (type === SET_CREDENTIALS) {
|
||||
if (payload && typeof payload === 'object') {
|
||||
return {
|
||||
...payload
|
||||
};
|
||||
}
|
||||
|
||||
return {};
|
||||
if (type === SET_CREDENTIALS) {
|
||||
if (payload && typeof payload === 'object') {
|
||||
return {
|
||||
...payload,
|
||||
};
|
||||
}
|
||||
|
||||
return state;
|
||||
return {};
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function isSwitcherEnabled(state = true, { type, payload = false }) {
|
||||
switch (type) {
|
||||
case SET_SWITCHER:
|
||||
if (typeof payload !== 'boolean') {
|
||||
throw new Error('Expected payload of boolean type');
|
||||
}
|
||||
switch (type) {
|
||||
case SET_SWITCHER:
|
||||
if (typeof payload !== 'boolean') {
|
||||
throw new Error('Expected payload of boolean type');
|
||||
}
|
||||
|
||||
return payload;
|
||||
return payload;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function isLoading(state = false, { type, payload = null }) {
|
||||
switch (type) {
|
||||
case SET_LOADING_STATE:
|
||||
return !!payload;
|
||||
switch (type) {
|
||||
case SET_LOADING_STATE:
|
||||
return !!payload;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function client(state = null, { type, payload = {} }) {
|
||||
switch (type) {
|
||||
case SET_CLIENT:
|
||||
return {
|
||||
id: payload.id,
|
||||
name: payload.name,
|
||||
description: payload.description
|
||||
};
|
||||
switch (type) {
|
||||
case SET_CLIENT:
|
||||
return {
|
||||
id: payload.id,
|
||||
name: payload.name,
|
||||
description: payload.description,
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function oauth(state = null, { type, payload = {} }) {
|
||||
switch (type) {
|
||||
case SET_OAUTH:
|
||||
return {
|
||||
clientId: payload.clientId,
|
||||
redirectUrl: payload.redirectUrl,
|
||||
responseType: payload.responseType,
|
||||
scope: payload.scope,
|
||||
prompt: payload.prompt,
|
||||
loginHint: payload.loginHint,
|
||||
state: payload.state
|
||||
};
|
||||
switch (type) {
|
||||
case SET_OAUTH:
|
||||
return {
|
||||
clientId: payload.clientId,
|
||||
redirectUrl: payload.redirectUrl,
|
||||
responseType: payload.responseType,
|
||||
scope: payload.scope,
|
||||
prompt: payload.prompt,
|
||||
loginHint: payload.loginHint,
|
||||
state: payload.state,
|
||||
};
|
||||
|
||||
case SET_OAUTH_RESULT:
|
||||
return {
|
||||
...state,
|
||||
success: payload.success,
|
||||
code: payload.code,
|
||||
displayCode: payload.displayCode
|
||||
};
|
||||
case SET_OAUTH_RESULT:
|
||||
return {
|
||||
...state,
|
||||
success: payload.success,
|
||||
code: payload.code,
|
||||
displayCode: payload.displayCode,
|
||||
};
|
||||
|
||||
case REQUIRE_PERMISSIONS_ACCEPT:
|
||||
return {
|
||||
...state,
|
||||
acceptRequired: true
|
||||
};
|
||||
case REQUIRE_PERMISSIONS_ACCEPT:
|
||||
return {
|
||||
...state,
|
||||
acceptRequired: true,
|
||||
};
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
function scopes(state = [], { type, payload = [] }) {
|
||||
switch (type) {
|
||||
case SET_SCOPES:
|
||||
return payload;
|
||||
switch (type) {
|
||||
case SET_SCOPES:
|
||||
return payload;
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLogin(state: Object): ?string {
|
||||
return state.auth.credentials.login || null;
|
||||
return state.auth.credentials.login || null;
|
||||
}
|
||||
|
||||
export function getCredentials(state: Object): Credentials {
|
||||
return state.auth.credentials;
|
||||
return state.auth.credentials;
|
||||
}
|
||||
|
@@ -2,42 +2,47 @@ import expect from 'test/unexpected';
|
||||
|
||||
import auth from 'components/auth/reducer';
|
||||
import {
|
||||
setLogin, SET_CREDENTIALS,
|
||||
setAccountSwitcher, SET_SWITCHER
|
||||
setLogin,
|
||||
SET_CREDENTIALS,
|
||||
setAccountSwitcher,
|
||||
SET_SWITCHER,
|
||||
} from 'components/auth/actions';
|
||||
|
||||
describe('components/auth/reducer', () => {
|
||||
describe(SET_CREDENTIALS, () => {
|
||||
it('should set login', () => {
|
||||
const expectedLogin = 'foo';
|
||||
describe(SET_CREDENTIALS, () => {
|
||||
it('should set login', () => {
|
||||
const expectedLogin = 'foo';
|
||||
|
||||
expect(auth(undefined, setLogin(expectedLogin)).credentials, 'to satisfy', {
|
||||
login: expectedLogin
|
||||
});
|
||||
});
|
||||
expect(
|
||||
auth(undefined, setLogin(expectedLogin)).credentials,
|
||||
'to satisfy',
|
||||
{
|
||||
login: expectedLogin,
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(SET_SWITCHER, () => {
|
||||
it('should be enabled by default', () =>
|
||||
expect(auth(undefined, {}), 'to satisfy', {
|
||||
isSwitcherEnabled: true,
|
||||
}));
|
||||
|
||||
it('should enable switcher', () => {
|
||||
const expectedValue = true;
|
||||
|
||||
expect(auth(undefined, setAccountSwitcher(expectedValue)), 'to satisfy', {
|
||||
isSwitcherEnabled: expectedValue,
|
||||
});
|
||||
});
|
||||
|
||||
describe(SET_SWITCHER, () => {
|
||||
it('should be enabled by default', () =>
|
||||
expect(auth(undefined, {}), 'to satisfy', {
|
||||
isSwitcherEnabled: true
|
||||
})
|
||||
);
|
||||
it('should disable switcher', () => {
|
||||
const expectedValue = false;
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
expect(auth(undefined, setAccountSwitcher(expectedValue)), 'to satisfy', {
|
||||
isSwitcherEnabled: expectedValue,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"registerTitle": "Sign Up",
|
||||
"yourNickname": "Your nickname",
|
||||
"yourEmail": "Your E‑mail",
|
||||
"accountPassword": "Account password",
|
||||
"repeatPassword": "Repeat password",
|
||||
"signUpButton": "Register",
|
||||
"acceptRules": "I agree with {link}",
|
||||
"termsOfService": "terms of service"
|
||||
"registerTitle": "Sign Up",
|
||||
"yourNickname": "Your nickname",
|
||||
"yourEmail": "Your E‑mail",
|
||||
"accountPassword": "Account password",
|
||||
"repeatPassword": "Repeat password",
|
||||
"signUpButton": "Register",
|
||||
"acceptRules": "I agree with {link}",
|
||||
"termsOfService": "terms of service"
|
||||
}
|
||||
|
@@ -6,19 +6,19 @@ import messages from './Register.intl.json';
|
||||
import Body from './RegisterBody';
|
||||
|
||||
export default factory({
|
||||
title: messages.registerTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'blue',
|
||||
label: messages.signUpButton
|
||||
title: messages.registerTitle,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'blue',
|
||||
label: messages.signUpButton,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
label: activationMessages.didNotReceivedEmail,
|
||||
payload: { requestEmail: true },
|
||||
},
|
||||
links: [
|
||||
{
|
||||
label: activationMessages.didNotReceivedEmail,
|
||||
payload: {requestEmail: true}
|
||||
},
|
||||
{
|
||||
label: forgotPasswordMessages.alreadyHaveCode
|
||||
}
|
||||
]
|
||||
{
|
||||
label: forgotPasswordMessages.alreadyHaveCode,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
@@ -13,66 +13,74 @@ import messages from './Register.intl.json';
|
||||
// TODO: password and username can be validate for length and sameness
|
||||
|
||||
export default class RegisterBody extends BaseAuthBody {
|
||||
static displayName = 'RegisterBody';
|
||||
static panelId = 'register';
|
||||
static displayName = 'RegisterBody';
|
||||
static panelId = 'register';
|
||||
|
||||
autoFocusField = 'username';
|
||||
autoFocusField = 'username';
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<Input {...this.bindField('username')}
|
||||
icon="user"
|
||||
color="blue"
|
||||
type="text"
|
||||
required
|
||||
placeholder={messages.yourNickname}
|
||||
/>
|
||||
<Input
|
||||
{...this.bindField('username')}
|
||||
icon="user"
|
||||
color="blue"
|
||||
type="text"
|
||||
required
|
||||
placeholder={messages.yourNickname}
|
||||
/>
|
||||
|
||||
<Input {...this.bindField('email')}
|
||||
icon="envelope"
|
||||
color="blue"
|
||||
type="email"
|
||||
required
|
||||
placeholder={messages.yourEmail}
|
||||
/>
|
||||
<Input
|
||||
{...this.bindField('email')}
|
||||
icon="envelope"
|
||||
color="blue"
|
||||
type="email"
|
||||
required
|
||||
placeholder={messages.yourEmail}
|
||||
/>
|
||||
|
||||
<Input {...this.bindField('password')}
|
||||
icon="key"
|
||||
color="blue"
|
||||
type="password"
|
||||
required
|
||||
placeholder={passwordMessages.accountPassword}
|
||||
/>
|
||||
<Input
|
||||
{...this.bindField('password')}
|
||||
icon="key"
|
||||
color="blue"
|
||||
type="password"
|
||||
required
|
||||
placeholder={passwordMessages.accountPassword}
|
||||
/>
|
||||
|
||||
<Input {...this.bindField('rePassword')}
|
||||
icon="key"
|
||||
color="blue"
|
||||
type="password"
|
||||
required
|
||||
placeholder={messages.repeatPassword}
|
||||
/>
|
||||
<Input
|
||||
{...this.bindField('rePassword')}
|
||||
icon="key"
|
||||
color="blue"
|
||||
type="password"
|
||||
required
|
||||
placeholder={messages.repeatPassword}
|
||||
/>
|
||||
|
||||
<Captcha {...this.bindField('captcha')} delay={600} />
|
||||
<Captcha {...this.bindField('captcha')} delay={600} />
|
||||
|
||||
<div className={styles.checkboxInput}>
|
||||
<Checkbox {...this.bindField('rulesAgreement')}
|
||||
color="blue"
|
||||
required
|
||||
label={
|
||||
<Message {...messages.acceptRules} values={{
|
||||
link: (
|
||||
<Link to="/rules" target="_blank">
|
||||
<Message {...messages.termsOfService} />
|
||||
</Link>
|
||||
)
|
||||
}} />
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div className={styles.checkboxInput}>
|
||||
<Checkbox
|
||||
{...this.bindField('rulesAgreement')}
|
||||
color="blue"
|
||||
required
|
||||
label={
|
||||
<Message
|
||||
{...messages.acceptRules}
|
||||
values={{
|
||||
link: (
|
||||
<Link to="/rules" target="_blank">
|
||||
<Message {...messages.termsOfService} />
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "Did not received an E‑mail",
|
||||
"specifyYourEmail": "Please, enter an E‑mail you've registered with and we will send you new activation code",
|
||||
"sendNewEmail": "Send new E‑mail"
|
||||
"title": "Did not received an E‑mail",
|
||||
"specifyYourEmail": "Please, enter an E‑mail you've registered with and we will send you new activation code",
|
||||
"sendNewEmail": "Send new E‑mail"
|
||||
}
|
||||
|
@@ -5,13 +5,13 @@ import messages from './ResendActivation.intl.json';
|
||||
import Body from './ResendActivationBody';
|
||||
|
||||
export default factory({
|
||||
title: messages.title,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'blue',
|
||||
label: messages.sendNewEmail
|
||||
},
|
||||
links: {
|
||||
label: forgotPasswordMessages.alreadyHaveCode
|
||||
}
|
||||
title: messages.title,
|
||||
body: Body,
|
||||
footer: {
|
||||
color: 'blue',
|
||||
label: messages.sendNewEmail,
|
||||
},
|
||||
links: {
|
||||
label: forgotPasswordMessages.alreadyHaveCode,
|
||||
},
|
||||
});
|
||||
|
@@ -10,32 +10,33 @@ import styles from './resendActivation.scss';
|
||||
import messages from './ResendActivation.intl.json';
|
||||
|
||||
export default class ResendActivation extends BaseAuthBody {
|
||||
static displayName = 'ResendActivation';
|
||||
static panelId = 'resendActivation';
|
||||
static hasGoBack = true;
|
||||
static displayName = 'ResendActivation';
|
||||
static panelId = 'resendActivation';
|
||||
static hasGoBack = true;
|
||||
|
||||
autoFocusField = 'email';
|
||||
autoFocusField = 'email';
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{this.renderErrors()}
|
||||
|
||||
<div className={styles.description}>
|
||||
<Message {...messages.specifyYourEmail} />
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
<Message {...messages.specifyYourEmail} />
|
||||
</div>
|
||||
|
||||
<Input {...this.bindField('email')}
|
||||
icon="envelope"
|
||||
color="blue"
|
||||
type="email"
|
||||
required
|
||||
placeholder={registerMessages.yourEmail}
|
||||
defaultValue={this.context.user.email}
|
||||
/>
|
||||
<Input
|
||||
{...this.bindField('email')}
|
||||
icon="envelope"
|
||||
color="blue"
|
||||
type="email"
|
||||
required
|
||||
placeholder={registerMessages.yourEmail}
|
||||
defaultValue={this.context.user.email}
|
||||
/>
|
||||
|
||||
<Captcha {...this.bindField('captcha')} delay={600} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Captcha {...this.bindField('captcha')} delay={600} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@@ -1,8 +1,8 @@
|
||||
@import '~components/ui/fonts.scss';
|
||||
|
||||
.description {
|
||||
font-family: $font-family-title;
|
||||
margin: 5px 0 19px;
|
||||
line-height: 1.4;
|
||||
font-size: 16px;
|
||||
font-family: $font-family-title;
|
||||
margin: 5px 0 19px;
|
||||
line-height: 1.4;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
@@ -3,7 +3,14 @@ import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import classNames from 'classnames';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { Input, TextArea, Button, Form, FormModel, Dropdown } from 'components/ui/form';
|
||||
import {
|
||||
Input,
|
||||
TextArea,
|
||||
Button,
|
||||
Form,
|
||||
FormModel,
|
||||
Dropdown,
|
||||
} from 'components/ui/form';
|
||||
import feedback from 'services/api/feedback';
|
||||
import icons from 'components/ui/icons.scss';
|
||||
import popupStyles from 'components/ui/popup/popup.scss';
|
||||
@@ -13,166 +20,177 @@ import styles from './contactForm.scss';
|
||||
import messages from './contactForm.intl.json';
|
||||
|
||||
const CONTACT_CATEGORIES = [
|
||||
// TODO: сюда позже проставить реальные id категорий с backend
|
||||
<Message key="m1" {...messages.cannotAccessMyAccount} />,
|
||||
<Message key="m2" {...messages.foundBugOnSite} />,
|
||||
<Message key="m3" {...messages.improvementsSuggestion} />,
|
||||
<Message key="m4" {...messages.integrationQuestion} />,
|
||||
<Message key="m5" {...messages.other} />
|
||||
// TODO: сюда позже проставить реальные id категорий с backend
|
||||
<Message key="m1" {...messages.cannotAccessMyAccount} />,
|
||||
<Message key="m2" {...messages.foundBugOnSite} />,
|
||||
<Message key="m3" {...messages.improvementsSuggestion} />,
|
||||
<Message key="m4" {...messages.integrationQuestion} />,
|
||||
<Message key="m5" {...messages.other} />,
|
||||
];
|
||||
|
||||
export class ContactForm extends Component {
|
||||
static displayName = 'ContactForm';
|
||||
static displayName = 'ContactForm';
|
||||
|
||||
static propTypes = {
|
||||
onClose: PropTypes.func,
|
||||
user: PropTypes.shape({
|
||||
email: PropTypes.string
|
||||
}).isRequired
|
||||
};
|
||||
static propTypes = {
|
||||
onClose: PropTypes.func,
|
||||
user: PropTypes.shape({
|
||||
email: PropTypes.string,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
static defaultProps = {
|
||||
onClose() {}
|
||||
};
|
||||
static defaultProps = {
|
||||
onClose() {},
|
||||
};
|
||||
|
||||
state = {
|
||||
isLoading: false,
|
||||
isSuccessfullySent: false
|
||||
};
|
||||
state = {
|
||||
isLoading: false,
|
||||
isSuccessfullySent: false,
|
||||
};
|
||||
|
||||
form = new FormModel();
|
||||
form = new FormModel();
|
||||
|
||||
render() {
|
||||
const {isSuccessfullySent} = this.state || {};
|
||||
const {onClose} = this.props;
|
||||
render() {
|
||||
const { isSuccessfullySent } = this.state || {};
|
||||
const { onClose } = this.props;
|
||||
|
||||
return (
|
||||
<div data-e2e="feedbackPopup" className={isSuccessfullySent ? styles.successState : styles.contactForm}>
|
||||
<div className={popupStyles.popup}>
|
||||
<div className={popupStyles.header}>
|
||||
<h2 className={popupStyles.headerTitle}>
|
||||
<Message {...messages.title} />
|
||||
</h2>
|
||||
<span className={classNames(icons.close, popupStyles.close)} onClick={onClose} />
|
||||
</div>
|
||||
return (
|
||||
<div
|
||||
data-e2e="feedbackPopup"
|
||||
className={
|
||||
isSuccessfullySent ? styles.successState : styles.contactForm
|
||||
}
|
||||
>
|
||||
<div className={popupStyles.popup}>
|
||||
<div className={popupStyles.header}>
|
||||
<h2 className={popupStyles.headerTitle}>
|
||||
<Message {...messages.title} />
|
||||
</h2>
|
||||
<span
|
||||
className={classNames(icons.close, popupStyles.close)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSuccessfullySent ? this.renderSuccess() : this.renderForm()}
|
||||
</div>
|
||||
{isSuccessfullySent ? this.renderSuccess() : this.renderForm()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderForm() {
|
||||
const { form } = this;
|
||||
const { user } = this.props;
|
||||
const { isLoading } = this.state;
|
||||
|
||||
return (
|
||||
<Form form={form} onSubmit={this.onSubmit} isLoading={isLoading}>
|
||||
<div className={popupStyles.body}>
|
||||
<div className={styles.philosophicalThought}>
|
||||
<Message {...messages.philosophicalThought} />
|
||||
</div>
|
||||
|
||||
<div className={styles.formDisclaimer}>
|
||||
<Message {...messages.disclaimer} />
|
||||
<br />
|
||||
</div>
|
||||
|
||||
<div className={styles.pairInputRow}>
|
||||
<div className={styles.pairInput}>
|
||||
<Input
|
||||
{...form.bindField('subject')}
|
||||
required
|
||||
label={messages.subject}
|
||||
skin="light"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
renderForm() {
|
||||
const {form} = this;
|
||||
const {user} = this.props;
|
||||
const {isLoading} = this.state;
|
||||
|
||||
return (
|
||||
<Form form={form}
|
||||
onSubmit={this.onSubmit}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<div className={popupStyles.body}>
|
||||
<div className={styles.philosophicalThought}>
|
||||
<Message {...messages.philosophicalThought} />
|
||||
</div>
|
||||
|
||||
<div className={styles.formDisclaimer}>
|
||||
<Message {...messages.disclaimer} /><br />
|
||||
</div>
|
||||
|
||||
<div className={styles.pairInputRow}>
|
||||
<div className={styles.pairInput}>
|
||||
<Input
|
||||
{...form.bindField('subject')}
|
||||
required
|
||||
label={messages.subject}
|
||||
skin="light"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.pairInput}>
|
||||
<Input
|
||||
{...form.bindField('email')}
|
||||
required
|
||||
label={messages.email}
|
||||
type="email"
|
||||
skin="light"
|
||||
defaultValue={user.email}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formMargin}>
|
||||
<Dropdown
|
||||
{...form.bindField('category')}
|
||||
label={messages.whichQuestion}
|
||||
items={CONTACT_CATEGORIES}
|
||||
block
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
{...form.bindField('message')}
|
||||
required
|
||||
label={messages.message}
|
||||
skin="light"
|
||||
minRows={6}
|
||||
maxRows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button label={messages.send} block type="submit" />
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
renderSuccess() {
|
||||
const {lastEmail: email} = this.state;
|
||||
const {onClose} = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.successBody}>
|
||||
<span className={styles.successIcon} />
|
||||
<div className={styles.successDescription}>
|
||||
<Message {...messages.youMessageReceived} />
|
||||
</div>
|
||||
<div className={styles.sentToEmail}>{email}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button label={messages.close} block onClick={onClose} />
|
||||
</div>
|
||||
<div className={styles.pairInput}>
|
||||
<Input
|
||||
{...form.bindField('email')}
|
||||
required
|
||||
label={messages.email}
|
||||
type="email"
|
||||
skin="light"
|
||||
defaultValue={user.email}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
|
||||
<div className={styles.formMargin}>
|
||||
<Dropdown
|
||||
{...form.bindField('category')}
|
||||
label={messages.whichQuestion}
|
||||
items={CONTACT_CATEGORIES}
|
||||
block
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TextArea
|
||||
{...form.bindField('message')}
|
||||
required
|
||||
label={messages.message}
|
||||
skin="light"
|
||||
minRows={6}
|
||||
maxRows={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button label={messages.send} block type="submit" />
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
renderSuccess() {
|
||||
const { lastEmail: email } = this.state;
|
||||
const { onClose } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.successBody}>
|
||||
<span className={styles.successIcon} />
|
||||
<div className={styles.successDescription}>
|
||||
<Message {...messages.youMessageReceived} />
|
||||
</div>
|
||||
<div className={styles.sentToEmail}>{email}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button label={messages.close} block onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onSubmit = () => {
|
||||
if (this.state.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit = () => {
|
||||
if (this.state.isLoading) {
|
||||
return;
|
||||
this.setState({ isLoading: true });
|
||||
|
||||
return feedback
|
||||
.send(this.form.serialize())
|
||||
.then(() =>
|
||||
this.setState({
|
||||
isSuccessfullySent: true,
|
||||
lastEmail: this.form.value('email'),
|
||||
}),
|
||||
)
|
||||
.catch(resp => {
|
||||
if (resp.errors) {
|
||||
this.form.setErrors(resp.errors);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({isLoading: true});
|
||||
return feedback.send(this.form.serialize())
|
||||
.then(() => this.setState({
|
||||
isSuccessfullySent: true,
|
||||
lastEmail: this.form.value('email')
|
||||
}))
|
||||
.catch((resp) => {
|
||||
if (resp.errors) {
|
||||
this.form.setErrors(resp.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn('Error sending feedback', resp);
|
||||
})
|
||||
.finally(() => this.setState({isLoading: false}));
|
||||
};
|
||||
logger.warn('Error sending feedback', resp);
|
||||
})
|
||||
.finally(() => this.setState({ isLoading: false }));
|
||||
};
|
||||
}
|
||||
|
||||
export default connect((state) => ({
|
||||
user: state.user
|
||||
export default connect(state => ({
|
||||
user: state.user,
|
||||
}))(ContactForm);
|
||||
|
@@ -10,197 +10,194 @@ import feedback from 'services/api/feedback';
|
||||
import { ContactForm } from 'components/contact/ContactForm';
|
||||
|
||||
describe('ContactForm', () => {
|
||||
describe('when rendered', () => {
|
||||
const user = {};
|
||||
let component;
|
||||
describe('when rendered', () => {
|
||||
const user = {};
|
||||
let component;
|
||||
|
||||
beforeEach(() => {
|
||||
component = shallow(<ContactForm user={user} />);
|
||||
});
|
||||
|
||||
[
|
||||
{
|
||||
type: 'Input',
|
||||
name: 'subject'
|
||||
}, {
|
||||
type: 'Input',
|
||||
name: 'email'
|
||||
}, {
|
||||
type: 'Dropdown',
|
||||
name: 'category'
|
||||
}, {
|
||||
type: 'TextArea',
|
||||
name: 'message'
|
||||
},
|
||||
].forEach((el) => {
|
||||
it(`should have ${el.name} field`, () => {
|
||||
expect(
|
||||
component.find(`${el.type}[name="${el.name}"]`),
|
||||
'to satisfy', {length: 1}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should contain Form', () => {
|
||||
expect(
|
||||
component.find('Form'),
|
||||
'to satisfy',
|
||||
{length: 1}
|
||||
);
|
||||
});
|
||||
|
||||
it('should contain submit Button', () => {
|
||||
expect(
|
||||
component.find('Button[type="submit"]'),
|
||||
'to satisfy',
|
||||
{length: 1}
|
||||
);
|
||||
});
|
||||
beforeEach(() => {
|
||||
component = shallow(<ContactForm user={user} />);
|
||||
});
|
||||
|
||||
describe('when rendered with user', () => {
|
||||
const user = {
|
||||
email: 'foo@bar.com'
|
||||
};
|
||||
let component;
|
||||
|
||||
beforeEach(() => {
|
||||
component = shallow(<ContactForm user={user} />);
|
||||
});
|
||||
|
||||
it('should render email field with user email', () => {
|
||||
expect(
|
||||
component.find('Input[name="email"]').prop('defaultValue'),
|
||||
'to equal', user.email
|
||||
);
|
||||
[
|
||||
{
|
||||
type: 'Input',
|
||||
name: 'subject',
|
||||
},
|
||||
{
|
||||
type: 'Input',
|
||||
name: 'email',
|
||||
},
|
||||
{
|
||||
type: 'Dropdown',
|
||||
name: 'category',
|
||||
},
|
||||
{
|
||||
type: 'TextArea',
|
||||
name: 'message',
|
||||
},
|
||||
].forEach(el => {
|
||||
it(`should have ${el.name} field`, () => {
|
||||
expect(component.find(`${el.type}[name="${el.name}"]`), 'to satisfy', {
|
||||
length: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('when email was successfully sent', () => {
|
||||
const user = {
|
||||
email: 'foo@bar.com'
|
||||
};
|
||||
let component;
|
||||
|
||||
beforeEach(() => {
|
||||
component = shallow(<ContactForm user={{user}} />);
|
||||
|
||||
component.setState({isSuccessfullySent: true});
|
||||
});
|
||||
|
||||
it('should not contain Form', () => {
|
||||
expect(
|
||||
component.find('Form'),
|
||||
'to satisfy',
|
||||
{length: 0}
|
||||
);
|
||||
});
|
||||
it('should contain Form', () => {
|
||||
expect(component.find('Form'), 'to satisfy', { length: 1 });
|
||||
});
|
||||
|
||||
xdescribe('validation', () => {
|
||||
const user = {
|
||||
email: 'foo@bar.com'
|
||||
};
|
||||
let component;
|
||||
let wrapper;
|
||||
it('should contain submit Button', () => {
|
||||
expect(component.find('Button[type="submit"]'), 'to satisfy', {
|
||||
length: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// TODO: add polyfill for from validation for jsdom
|
||||
describe('when rendered with user', () => {
|
||||
const user = {
|
||||
email: 'foo@bar.com',
|
||||
};
|
||||
let component;
|
||||
|
||||
wrapper = mount(
|
||||
<IntlProvider locale="en" defaultLocale="en">
|
||||
<ContactForm user={{user}} ref={(el) => component = el} />
|
||||
</IntlProvider>
|
||||
);
|
||||
});
|
||||
|
||||
it('should require email, subject and message', () => {
|
||||
// wrapper.find('[type="submit"]').simulate('click');
|
||||
wrapper.find('form').simulate('submit');
|
||||
|
||||
expect(component.form.hasErrors(), 'to be true');
|
||||
});
|
||||
beforeEach(() => {
|
||||
component = shallow(<ContactForm user={user} />);
|
||||
});
|
||||
|
||||
describe('when user submits form', () => {
|
||||
const user = {
|
||||
email: 'foo@bar.com'
|
||||
};
|
||||
let component;
|
||||
let wrapper;
|
||||
const requestData = {
|
||||
email: user.email,
|
||||
subject: 'Test subject',
|
||||
message: 'Test message'
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
sinon.stub(feedback, 'send');
|
||||
// TODO: add polyfill for from validation for jsdom
|
||||
if (!Element.prototype.checkValidity) {
|
||||
Element.prototype.checkValidity = () => true;
|
||||
}
|
||||
|
||||
// TODO: try to rewrite with unexpected-react
|
||||
wrapper = mount(
|
||||
<IntlProvider locale="en" defaultLocale="en">
|
||||
<ContactForm user={{user}} ref={(el) => component = el} />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
wrapper.find('input[name="email"]').getDOMNode().value = requestData.email;
|
||||
wrapper.find('input[name="subject"]').getDOMNode().value = requestData.subject;
|
||||
wrapper.find('textarea[name="message"]').getDOMNode().value = requestData.message;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
feedback.send.restore();
|
||||
});
|
||||
|
||||
xit('should call onSubmit', () => {
|
||||
sinon.stub(component, 'onSubmit');
|
||||
|
||||
wrapper.find('form').simulate('submit');
|
||||
|
||||
expect(component.onSubmit, 'was called');
|
||||
});
|
||||
|
||||
it('should call send with required data', () => {
|
||||
feedback.send.returns(Promise.resolve());
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(feedback.send, 'to have a call satisfying', [
|
||||
requestData
|
||||
]);
|
||||
});
|
||||
|
||||
it('should set isSuccessfullySent', () => {
|
||||
feedback.send.returns(Promise.resolve());
|
||||
|
||||
return component.onSubmit().then(() =>
|
||||
expect(component.state, 'to satisfy', {isSuccessfullySent: true})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle isLoading during request', () => {
|
||||
feedback.send.returns(Promise.resolve());
|
||||
|
||||
const promise = component.onSubmit();
|
||||
|
||||
expect(component.state, 'to satisfy', {isLoading: true});
|
||||
|
||||
return promise.then(() =>
|
||||
expect(component.state, 'to satisfy', {isLoading: false})
|
||||
);
|
||||
});
|
||||
|
||||
it('should render success message with user email', () => {
|
||||
feedback.send.returns(Promise.resolve());
|
||||
|
||||
return component.onSubmit().then(() =>
|
||||
expect(wrapper.text(), 'to contain', user.email)
|
||||
);
|
||||
});
|
||||
it('should render email field with user email', () => {
|
||||
expect(
|
||||
component.find('Input[name="email"]').prop('defaultValue'),
|
||||
'to equal',
|
||||
user.email,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when email was successfully sent', () => {
|
||||
const user = {
|
||||
email: 'foo@bar.com',
|
||||
};
|
||||
let component;
|
||||
|
||||
beforeEach(() => {
|
||||
component = shallow(<ContactForm user={{ user }} />);
|
||||
|
||||
component.setState({ isSuccessfullySent: true });
|
||||
});
|
||||
|
||||
it('should not contain Form', () => {
|
||||
expect(component.find('Form'), 'to satisfy', { length: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
xdescribe('validation', () => {
|
||||
const user = {
|
||||
email: 'foo@bar.com',
|
||||
};
|
||||
let component;
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
// TODO: add polyfill for from validation for jsdom
|
||||
|
||||
wrapper = mount(
|
||||
<IntlProvider locale="en" defaultLocale="en">
|
||||
<ContactForm user={{ user }} ref={el => (component = el)} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
});
|
||||
|
||||
it('should require email, subject and message', () => {
|
||||
// wrapper.find('[type="submit"]').simulate('click');
|
||||
wrapper.find('form').simulate('submit');
|
||||
|
||||
expect(component.form.hasErrors(), 'to be true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('when user submits form', () => {
|
||||
const user = {
|
||||
email: 'foo@bar.com',
|
||||
};
|
||||
let component;
|
||||
let wrapper;
|
||||
const requestData = {
|
||||
email: user.email,
|
||||
subject: 'Test subject',
|
||||
message: 'Test message',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
sinon.stub(feedback, 'send');
|
||||
|
||||
// TODO: add polyfill for from validation for jsdom
|
||||
if (!Element.prototype.checkValidity) {
|
||||
Element.prototype.checkValidity = () => true;
|
||||
}
|
||||
|
||||
// TODO: try to rewrite with unexpected-react
|
||||
wrapper = mount(
|
||||
<IntlProvider locale="en" defaultLocale="en">
|
||||
<ContactForm user={{ user }} ref={el => (component = el)} />
|
||||
</IntlProvider>,
|
||||
);
|
||||
|
||||
wrapper.find('input[name="email"]').getDOMNode().value =
|
||||
requestData.email;
|
||||
wrapper.find('input[name="subject"]').getDOMNode().value =
|
||||
requestData.subject;
|
||||
wrapper.find('textarea[name="message"]').getDOMNode().value =
|
||||
requestData.message;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
feedback.send.restore();
|
||||
});
|
||||
|
||||
xit('should call onSubmit', () => {
|
||||
sinon.stub(component, 'onSubmit');
|
||||
|
||||
wrapper.find('form').simulate('submit');
|
||||
|
||||
expect(component.onSubmit, 'was called');
|
||||
});
|
||||
|
||||
it('should call send with required data', () => {
|
||||
feedback.send.returns(Promise.resolve());
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(feedback.send, 'to have a call satisfying', [requestData]);
|
||||
});
|
||||
|
||||
it('should set isSuccessfullySent', () => {
|
||||
feedback.send.returns(Promise.resolve());
|
||||
|
||||
return component
|
||||
.onSubmit()
|
||||
.then(() =>
|
||||
expect(component.state, 'to satisfy', { isSuccessfullySent: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle isLoading during request', () => {
|
||||
feedback.send.returns(Promise.resolve());
|
||||
|
||||
const promise = component.onSubmit();
|
||||
|
||||
expect(component.state, 'to satisfy', { isLoading: true });
|
||||
|
||||
return promise.then(() =>
|
||||
expect(component.state, 'to satisfy', { isLoading: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should render success message with user email', () => {
|
||||
feedback.send.returns(Promise.resolve());
|
||||
|
||||
return component
|
||||
.onSubmit()
|
||||
.then(() => expect(wrapper.text(), 'to contain', user.email));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -8,31 +8,25 @@ import ContactForm from './ContactForm';
|
||||
type OwnProps = $Exact<ElementConfig<'a'>>;
|
||||
|
||||
type Props = {
|
||||
...OwnProps,
|
||||
createContactPopup: () => void,
|
||||
...OwnProps,
|
||||
createContactPopup: () => void,
|
||||
};
|
||||
|
||||
function ContactLink({
|
||||
createContactPopup,
|
||||
...props
|
||||
}: Props) {
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
data-e2e-button="feedbackPopup"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
function ContactLink({ createContactPopup, ...props }: Props) {
|
||||
return (
|
||||
<a
|
||||
href="#"
|
||||
data-e2e-button="feedbackPopup"
|
||||
onClick={event => {
|
||||
event.preventDefault();
|
||||
|
||||
createContactPopup();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
createContactPopup();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect<Props, OwnProps, _, _, _, _>(
|
||||
null,
|
||||
{
|
||||
createContactPopup: () => createPopup({ Popup: ContactForm })
|
||||
}
|
||||
)(ContactLink);
|
||||
export default connect<Props, OwnProps, _, _, _, _>(null, {
|
||||
createContactPopup: () => createPopup({ Popup: ContactForm }),
|
||||
})(ContactLink);
|
||||
|
@@ -1,19 +1,19 @@
|
||||
{
|
||||
"title": "Feedback form",
|
||||
"subject": "Subject",
|
||||
"email": "E‑mail",
|
||||
"message": "Message",
|
||||
"send": "Send",
|
||||
"philosophicalThought": "Properly formulated question — half of the answer",
|
||||
"disclaimer": "Please formulate your feedback providing as much useful information, as possible to help us understand your problem and solve it",
|
||||
"whichQuestion" : "What are you interested in?",
|
||||
"title": "Feedback form",
|
||||
"subject": "Subject",
|
||||
"email": "E‑mail",
|
||||
"message": "Message",
|
||||
"send": "Send",
|
||||
"philosophicalThought": "Properly formulated question — half of the answer",
|
||||
"disclaimer": "Please formulate your feedback providing as much useful information, as possible to help us understand your problem and solve it",
|
||||
"whichQuestion": "What are you interested in?",
|
||||
|
||||
"cannotAccessMyAccount" : "Can not access my account",
|
||||
"foundBugOnSite" : "I found a bug on the site",
|
||||
"improvementsSuggestion" : "I have a suggestion for improving the functional",
|
||||
"integrationQuestion" : "Service integration question",
|
||||
"other" : "Other",
|
||||
"cannotAccessMyAccount": "Can not access my account",
|
||||
"foundBugOnSite": "I found a bug on the site",
|
||||
"improvementsSuggestion": "I have a suggestion for improving the functional",
|
||||
"integrationQuestion": "Service integration question",
|
||||
"other": "Other",
|
||||
|
||||
"youMessageReceived" : "Your message was received. We will respond to you shortly. The answer will come to your E‑mail:",
|
||||
"close" : "Close"
|
||||
"youMessageReceived": "Your message was received. We will respond to you shortly. The answer will come to your E‑mail:",
|
||||
"close": "Close"
|
||||
}
|
||||
|
@@ -5,81 +5,81 @@
|
||||
/* Form state */
|
||||
|
||||
.contactForm {
|
||||
composes: popupWrapper from '~components/ui/popup/popup.scss';
|
||||
composes: popupWrapper from '~components/ui/popup/popup.scss';
|
||||
|
||||
@include popupBounding(500px);
|
||||
@include popupBounding(500px);
|
||||
}
|
||||
|
||||
.philosophicalThought {
|
||||
font-family: $font-family-title;
|
||||
font-size: 19px;
|
||||
color: $green;
|
||||
text-align: center;
|
||||
margin-bottom: 5px;
|
||||
font-family: $font-family-title;
|
||||
font-size: 19px;
|
||||
color: $green;
|
||||
text-align: center;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.formDisclaimer {
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
margin: 0 auto 10px;
|
||||
font-size: 12px;
|
||||
line-height: 14px;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
margin: 0 auto 10px;
|
||||
}
|
||||
|
||||
.pairInputRow {
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pairInput {
|
||||
width: 50%;
|
||||
width: 50%;
|
||||
|
||||
&:first-of-type {
|
||||
margin-right: $popupPadding;
|
||||
}
|
||||
&:first-of-type {
|
||||
margin-right: $popupPadding;
|
||||
}
|
||||
}
|
||||
|
||||
.formMargin {
|
||||
margin-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Success State */
|
||||
|
||||
.successState {
|
||||
composes: popupWrapper from '~components/ui/popup/popup.scss';
|
||||
composes: popupWrapper from '~components/ui/popup/popup.scss';
|
||||
|
||||
@include popupBounding(320px);
|
||||
@include popupBounding(320px);
|
||||
}
|
||||
|
||||
.successBody {
|
||||
composes: body from '~components/ui/popup/popup.scss';
|
||||
composes: body from '~components/ui/popup/popup.scss';
|
||||
|
||||
text-align: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.successDescription {
|
||||
@extend .formDisclaimer;
|
||||
@extend .formDisclaimer;
|
||||
|
||||
margin-bottom: 15px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.successIcon {
|
||||
composes: checkmark from '~components/ui/icons.scss';
|
||||
composes: checkmark from '~components/ui/icons.scss';
|
||||
|
||||
font-size: 90px;
|
||||
color: #AAA;
|
||||
margin-bottom: 20px;
|
||||
line-height: 71px;
|
||||
font-size: 90px;
|
||||
color: #aaa;
|
||||
margin-bottom: 20px;
|
||||
line-height: 71px;
|
||||
}
|
||||
|
||||
.sentToEmail {
|
||||
font-family: $font-family-title;
|
||||
color: #444;
|
||||
font-size: 18px;
|
||||
font-family: $font-family-title;
|
||||
color: #444;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Common */
|
||||
|
||||
.footer {
|
||||
margin-top: 0;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"accountsForDevelopers": "Ely.by Accounts for developers",
|
||||
"accountsAllowsYouYoUseOauth2": "Ely.by Accounts service provides users with a quick and easy-to-use way to login to your site, launcher or Minecraft server via OAuth2 authorization protocol. You can find more information about integration with Ely.by Accounts in {ourDocumentation}.",
|
||||
"ourDocumentation": "our documentation",
|
||||
"ifYouHaveAnyTroubles": "If you are experiencing difficulties, you can always use {feedback}. We'll surely help you.",
|
||||
"feedback": "feedback",
|
||||
"weDontKnowAnythingAboutYou": "We don't know anything about you yet.",
|
||||
"youMustAuthToBegin": "You have to authorize to start.",
|
||||
"authorization": "Authorization",
|
||||
"youDontHaveAnyApplication": "You don't have any app registered yet.",
|
||||
"shallWeStart": "Shall we start?",
|
||||
"addNew": "Add new",
|
||||
"yourApplications": "Your applications:",
|
||||
"countUsers": "{count, plural, =0 {No users} one {# user} other {# users}}",
|
||||
"ifYouSuspectingThatSecretHasBeenCompromised": "If you are suspecting that your Client Secret has been compromised, then you may want to reset it value. It'll cause recall of the all \"access\" and \"refresh\" tokens that have been issued. You can also recall all issued tokens without changing Client Secret.",
|
||||
"revokeAllTokens": "Revoke all tokens",
|
||||
"resetClientSecret": "Reset Client Secret",
|
||||
"delete": "Delete",
|
||||
"editDescription": "{icon} Edit description",
|
||||
"allRefreshTokensWillBecomeInvalid": "All \"refresh\" tokens will become invalid and after next authorization the user will get permissions prompt.",
|
||||
"appAndAllTokenWillBeDeleted": "Application and all associated tokens will be deleted.",
|
||||
"takeCareAccessTokensInvalidation": "Take care because \"access\" tokens won't be invalidated immediately.",
|
||||
"cancel": "Cancel",
|
||||
"continue": "Continue",
|
||||
"performing": "Performing…"
|
||||
"accountsForDevelopers": "Ely.by Accounts for developers",
|
||||
"accountsAllowsYouYoUseOauth2": "Ely.by Accounts service provides users with a quick and easy-to-use way to login to your site, launcher or Minecraft server via OAuth2 authorization protocol. You can find more information about integration with Ely.by Accounts in {ourDocumentation}.",
|
||||
"ourDocumentation": "our documentation",
|
||||
"ifYouHaveAnyTroubles": "If you are experiencing difficulties, you can always use {feedback}. We'll surely help you.",
|
||||
"feedback": "feedback",
|
||||
"weDontKnowAnythingAboutYou": "We don't know anything about you yet.",
|
||||
"youMustAuthToBegin": "You have to authorize to start.",
|
||||
"authorization": "Authorization",
|
||||
"youDontHaveAnyApplication": "You don't have any app registered yet.",
|
||||
"shallWeStart": "Shall we start?",
|
||||
"addNew": "Add new",
|
||||
"yourApplications": "Your applications:",
|
||||
"countUsers": "{count, plural, =0 {No users} one {# user} other {# users}}",
|
||||
"ifYouSuspectingThatSecretHasBeenCompromised": "If you are suspecting that your Client Secret has been compromised, then you may want to reset it value. It'll cause recall of the all \"access\" and \"refresh\" tokens that have been issued. You can also recall all issued tokens without changing Client Secret.",
|
||||
"revokeAllTokens": "Revoke all tokens",
|
||||
"resetClientSecret": "Reset Client Secret",
|
||||
"delete": "Delete",
|
||||
"editDescription": "{icon} Edit description",
|
||||
"allRefreshTokensWillBecomeInvalid": "All \"refresh\" tokens will become invalid and after next authorization the user will get permissions prompt.",
|
||||
"appAndAllTokenWillBeDeleted": "Application and all associated tokens will be deleted.",
|
||||
"takeCareAccessTokensInvalidation": "Take care because \"access\" tokens won't be invalidated immediately.",
|
||||
"cancel": "Cancel",
|
||||
"continue": "Continue",
|
||||
"performing": "Performing…"
|
||||
}
|
||||
|
@@ -16,147 +16,144 @@ import toolsIcon from './icons/tools.svg';
|
||||
import ApplicationsList from './list';
|
||||
|
||||
type Props = {
|
||||
clientId?: ?string,
|
||||
resetClientId: () => void, // notify parent to remove clientId from current location.href
|
||||
displayForGuest: bool,
|
||||
applications: Array<OauthAppResponse>,
|
||||
isLoading: bool,
|
||||
deleteApp: string => Promise<any>,
|
||||
resetApp: (string, bool) => Promise<any>
|
||||
clientId?: ?string,
|
||||
resetClientId: () => void, // notify parent to remove clientId from current location.href
|
||||
displayForGuest: boolean,
|
||||
applications: Array<OauthAppResponse>,
|
||||
isLoading: boolean,
|
||||
deleteApp: string => Promise<any>,
|
||||
resetApp: (string, boolean) => Promise<any>,
|
||||
};
|
||||
|
||||
export default class ApplicationsIndex extends Component<Props> {
|
||||
render() {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.welcomeContainer}>
|
||||
<Message {...messages.accountsForDevelopers}>
|
||||
{(pageTitle: string) => (
|
||||
<h2 className={styles.welcomeTitle}>
|
||||
<Helmet title={pageTitle} />
|
||||
{pageTitle}
|
||||
</h2>
|
||||
)}
|
||||
</Message>
|
||||
<div className={styles.welcomeTitleDelimiter} />
|
||||
<div className={styles.welcomeParagraph}>
|
||||
<Message
|
||||
{...messages.accountsAllowsYouYoUseOauth2}
|
||||
values={{
|
||||
ourDocumentation: (
|
||||
<a
|
||||
href="https://docs.ely.by/en/oauth.html"
|
||||
target="_blank"
|
||||
>
|
||||
<Message
|
||||
{...messages.ourDocumentation}
|
||||
/>
|
||||
</a>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.welcomeParagraph}>
|
||||
<Message
|
||||
{...messages.ifYouHaveAnyTroubles}
|
||||
values={{
|
||||
feedback: (
|
||||
<ContactLink>
|
||||
<Message {...messages.feedback} />
|
||||
</ContactLink>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
render() {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.welcomeContainer}>
|
||||
<Message {...messages.accountsForDevelopers}>
|
||||
{(pageTitle: string) => (
|
||||
<h2 className={styles.welcomeTitle}>
|
||||
<Helmet title={pageTitle} />
|
||||
{pageTitle}
|
||||
</h2>
|
||||
)}
|
||||
</Message>
|
||||
<div className={styles.welcomeTitleDelimiter} />
|
||||
<div className={styles.welcomeParagraph}>
|
||||
<Message
|
||||
{...messages.accountsAllowsYouYoUseOauth2}
|
||||
values={{
|
||||
ourDocumentation: (
|
||||
<a href="https://docs.ely.by/en/oauth.html" target="_blank">
|
||||
<Message {...messages.ourDocumentation} />
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.welcomeParagraph}>
|
||||
<Message
|
||||
{...messages.ifYouHaveAnyTroubles}
|
||||
values={{
|
||||
feedback: (
|
||||
<ContactLink>
|
||||
<Message {...messages.feedback} />
|
||||
</ContactLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{this.getContent()}
|
||||
</div>
|
||||
);
|
||||
{this.getContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getContent() {
|
||||
const {
|
||||
displayForGuest,
|
||||
applications,
|
||||
isLoading,
|
||||
resetApp,
|
||||
deleteApp,
|
||||
clientId,
|
||||
resetClientId,
|
||||
} = this.props;
|
||||
|
||||
if (applications.length > 0) {
|
||||
return (
|
||||
<ApplicationsList
|
||||
applications={applications}
|
||||
resetApp={resetApp}
|
||||
deleteApp={deleteApp}
|
||||
clientId={clientId}
|
||||
resetClientId={resetClientId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
getContent() {
|
||||
const {
|
||||
displayForGuest,
|
||||
applications,
|
||||
isLoading,
|
||||
resetApp,
|
||||
deleteApp,
|
||||
clientId,
|
||||
resetClientId
|
||||
} = this.props;
|
||||
|
||||
if (applications.length > 0) {
|
||||
return (
|
||||
<ApplicationsList
|
||||
applications={applications}
|
||||
resetApp={resetApp}
|
||||
deleteApp={deleteApp}
|
||||
clientId={clientId}
|
||||
resetClientId={resetClientId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (displayForGuest) {
|
||||
return <Guest />;
|
||||
}
|
||||
|
||||
return <Loader noApps={!isLoading} />;
|
||||
if (displayForGuest) {
|
||||
return <Guest />;
|
||||
}
|
||||
|
||||
return <Loader noApps={!isLoading} />;
|
||||
}
|
||||
}
|
||||
|
||||
function Loader({ noApps }: { noApps: bool }) {
|
||||
return (
|
||||
<div className={styles.emptyState} data-e2e={noApps ? 'noApps' : 'loading'}>
|
||||
<img
|
||||
src={noApps ? cubeIcon : loadingCubeIcon}
|
||||
className={styles.emptyStateIcon}
|
||||
/>
|
||||
function Loader({ noApps }: { noApps: boolean }) {
|
||||
return (
|
||||
<div className={styles.emptyState} data-e2e={noApps ? 'noApps' : 'loading'}>
|
||||
<img
|
||||
src={noApps ? cubeIcon : loadingCubeIcon}
|
||||
className={styles.emptyStateIcon}
|
||||
/>
|
||||
|
||||
<div className={classNames(styles.noAppsContainer, {
|
||||
[styles.noAppsAnimating]: noApps
|
||||
})}>
|
||||
<div className={styles.emptyStateText}>
|
||||
<div>
|
||||
<Message {...messages.youDontHaveAnyApplication} />
|
||||
</div>
|
||||
<div>
|
||||
<Message {...messages.shallWeStart} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LinkButton
|
||||
to="/dev/applications/new"
|
||||
data-e2e="newApp"
|
||||
label={messages.addNew}
|
||||
color={COLOR_GREEN}
|
||||
className={styles.emptyStateActionButton}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className={classNames(styles.noAppsContainer, {
|
||||
[styles.noAppsAnimating]: noApps,
|
||||
})}
|
||||
>
|
||||
<div className={styles.emptyStateText}>
|
||||
<div>
|
||||
<Message {...messages.youDontHaveAnyApplication} />
|
||||
</div>
|
||||
<div>
|
||||
<Message {...messages.shallWeStart} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
<LinkButton
|
||||
to="/dev/applications/new"
|
||||
data-e2e="newApp"
|
||||
label={messages.addNew}
|
||||
color={COLOR_GREEN}
|
||||
className={styles.emptyStateActionButton}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Guest() {
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<img src={toolsIcon} className={styles.emptyStateIcon} />
|
||||
<div className={styles.emptyStateText}>
|
||||
<div>
|
||||
<Message {...messages.weDontKnowAnythingAboutYou} />
|
||||
</div>
|
||||
<div>
|
||||
<Message {...messages.youMustAuthToBegin} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LinkButton
|
||||
to="/login"
|
||||
label={messages.authorization}
|
||||
color={COLOR_BLUE}
|
||||
className={styles.emptyStateActionButton}
|
||||
/>
|
||||
return (
|
||||
<div className={styles.emptyState}>
|
||||
<img src={toolsIcon} className={styles.emptyStateIcon} />
|
||||
<div className={styles.emptyStateText}>
|
||||
<div>
|
||||
<Message {...messages.weDontKnowAnythingAboutYou} />
|
||||
</div>
|
||||
);
|
||||
<div>
|
||||
<Message {...messages.youMustAuthToBegin} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LinkButton
|
||||
to="/login"
|
||||
label={messages.authorization}
|
||||
color={COLOR_BLUE}
|
||||
className={styles.emptyStateActionButton}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -6,72 +6,83 @@ import oauth from 'services/api/oauth';
|
||||
|
||||
import type { Apps } from './reducer';
|
||||
|
||||
type SetAvailableAction = { type: 'apps:setAvailable', payload: Array<OauthAppResponse> };
|
||||
type SetAvailableAction = {
|
||||
type: 'apps:setAvailable',
|
||||
payload: Array<OauthAppResponse>,
|
||||
};
|
||||
type DeleteAppAction = { type: 'apps:deleteApp', payload: string };
|
||||
type AddAppAction = { type: 'apps:addApp', payload: OauthAppResponse };
|
||||
export type Action = SetAvailableAction | DeleteAppAction | AddAppAction;
|
||||
|
||||
export function setAppsList(apps: Array<OauthAppResponse>): SetAvailableAction {
|
||||
return {
|
||||
type: 'apps:setAvailable',
|
||||
payload: apps,
|
||||
};
|
||||
return {
|
||||
type: 'apps:setAvailable',
|
||||
payload: apps,
|
||||
};
|
||||
}
|
||||
|
||||
export function getApp(state: {apps: Apps}, clientId: string): ?OauthAppResponse {
|
||||
return state.apps.available.find((app) => app.clientId === clientId) || null;
|
||||
export function getApp(
|
||||
state: { apps: Apps },
|
||||
clientId: string,
|
||||
): ?OauthAppResponse {
|
||||
return state.apps.available.find(app => app.clientId === clientId) || null;
|
||||
}
|
||||
|
||||
export function fetchApp(clientId: string) {
|
||||
return async (dispatch: Dispatch<any>): Promise<void> => {
|
||||
const app = await oauth.getApp(clientId);
|
||||
return async (dispatch: Dispatch<any>): Promise<void> => {
|
||||
const app = await oauth.getApp(clientId);
|
||||
|
||||
dispatch(addApp(app));
|
||||
};
|
||||
dispatch(addApp(app));
|
||||
};
|
||||
}
|
||||
|
||||
function addApp(app: OauthAppResponse): AddAppAction {
|
||||
return {
|
||||
type: 'apps:addApp',
|
||||
payload: app
|
||||
};
|
||||
return {
|
||||
type: 'apps:addApp',
|
||||
payload: app,
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchAvailableApps() {
|
||||
return async (dispatch: Dispatch<any>, getState: () => {user: User}): Promise<void> => {
|
||||
const { id } = getState().user;
|
||||
if (!id) {
|
||||
dispatch(setAppsList([]));
|
||||
return;
|
||||
}
|
||||
return async (
|
||||
dispatch: Dispatch<any>,
|
||||
getState: () => { user: User },
|
||||
): Promise<void> => {
|
||||
const { id } = getState().user;
|
||||
|
||||
const apps = await oauth.getAppsByUser(id);
|
||||
if (!id) {
|
||||
dispatch(setAppsList([]));
|
||||
|
||||
dispatch(setAppsList(apps));
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const apps = await oauth.getAppsByUser(id);
|
||||
|
||||
dispatch(setAppsList(apps));
|
||||
};
|
||||
}
|
||||
|
||||
export function deleteApp(clientId: string) {
|
||||
return async (dispatch: Dispatch<any>): Promise<void> => {
|
||||
await oauth.delete(clientId);
|
||||
return async (dispatch: Dispatch<any>): Promise<void> => {
|
||||
await oauth.delete(clientId);
|
||||
|
||||
dispatch(createDeleteAppAction(clientId));
|
||||
};
|
||||
dispatch(createDeleteAppAction(clientId));
|
||||
};
|
||||
}
|
||||
|
||||
function createDeleteAppAction(clientId: string): DeleteAppAction {
|
||||
return {
|
||||
type: 'apps:deleteApp',
|
||||
payload: clientId
|
||||
};
|
||||
return {
|
||||
type: 'apps:deleteApp',
|
||||
payload: clientId,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetApp(clientId: string, resetSecret: bool) {
|
||||
return async (dispatch: Dispatch<any>): Promise<void> => {
|
||||
const { data: app } = await oauth.reset(clientId, resetSecret);
|
||||
export function resetApp(clientId: string, resetSecret: boolean) {
|
||||
return async (dispatch: Dispatch<any>): Promise<void> => {
|
||||
const { data: app } = await oauth.reset(clientId, resetSecret);
|
||||
|
||||
if (resetSecret) {
|
||||
dispatch(addApp(app));
|
||||
}
|
||||
};
|
||||
if (resetSecret) {
|
||||
dispatch(addApp(app));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@@ -1,20 +1,20 @@
|
||||
{
|
||||
"creatingApplication": "Creating an application",
|
||||
"website": "Web site",
|
||||
"minecraftServer": "Minecraft server",
|
||||
"toDisplayRegistrationFormChooseType": "To display registration form for a new application choose necessary type.",
|
||||
"applicationName": "Application name:",
|
||||
"appDescriptionWillBeAlsoVisibleOnOauthPage": "Application's description will be displayed at the authorization page too. It isn't a required field. In authorization process the value may be overridden.",
|
||||
"description": "Description:",
|
||||
"websiteLinkWillBeUsedAsAdditionalId": "Site's link is optional, but it can be used as an additional identifier of the application.",
|
||||
"websiteLink": "Website link:",
|
||||
"redirectUriLimitsAllowableBaseAddress": "Redirection URI (redirectUri) determines a base address, that user will be allowed to be redirected to after authorization. In order to improve security it's better to use the whole path instead of just a domain name. For example: https://example.com/oauth/ely.",
|
||||
"redirectUri": "Redirect URI:",
|
||||
"createApplication": "Create application",
|
||||
"serverName": "Server name:",
|
||||
"ipAddressIsOptionButPreferable": "IP address is optional, but is very preferable. It might become handy in case of we suddenly decide to play on your server with the entire band (=",
|
||||
"serverIp": "Server IP:",
|
||||
"youCanAlsoSpecifyServerSite": "You also can specify either server's site URL or its community in a social network.",
|
||||
"updatingApplication": "Updating an application",
|
||||
"updateApplication" : "Update application"
|
||||
"creatingApplication": "Creating an application",
|
||||
"website": "Web site",
|
||||
"minecraftServer": "Minecraft server",
|
||||
"toDisplayRegistrationFormChooseType": "To display registration form for a new application choose necessary type.",
|
||||
"applicationName": "Application name:",
|
||||
"appDescriptionWillBeAlsoVisibleOnOauthPage": "Application's description will be displayed at the authorization page too. It isn't a required field. In authorization process the value may be overridden.",
|
||||
"description": "Description:",
|
||||
"websiteLinkWillBeUsedAsAdditionalId": "Site's link is optional, but it can be used as an additional identifier of the application.",
|
||||
"websiteLink": "Website link:",
|
||||
"redirectUriLimitsAllowableBaseAddress": "Redirection URI (redirectUri) determines a base address, that user will be allowed to be redirected to after authorization. In order to improve security it's better to use the whole path instead of just a domain name. For example: https://example.com/oauth/ely.",
|
||||
"redirectUri": "Redirect URI:",
|
||||
"createApplication": "Create application",
|
||||
"serverName": "Server name:",
|
||||
"ipAddressIsOptionButPreferable": "IP address is optional, but is very preferable. It might become handy in case of we suddenly decide to play on your server with the entire band (=",
|
||||
"serverIp": "Server IP:",
|
||||
"youCanAlsoSpecifyServerSite": "You also can specify either server's site URL or its community in a social network.",
|
||||
"updatingApplication": "Updating an application",
|
||||
"updateApplication": "Update application"
|
||||
}
|
||||
|
@@ -19,109 +19,120 @@ import WebsiteType from './WebsiteType';
|
||||
import MinecraftServerType from './MinecraftServerType';
|
||||
|
||||
const typeToForm: {
|
||||
[key: ApplicationType]: {
|
||||
label: MessageDescriptor,
|
||||
component: ComponentType<any>,
|
||||
},
|
||||
[key: ApplicationType]: {
|
||||
label: MessageDescriptor,
|
||||
component: ComponentType<any>,
|
||||
},
|
||||
} = {
|
||||
[TYPE_APPLICATION]: {
|
||||
label: messages.website,
|
||||
component: WebsiteType,
|
||||
},
|
||||
[TYPE_MINECRAFT_SERVER]: {
|
||||
label: messages.minecraftServer,
|
||||
component: MinecraftServerType,
|
||||
},
|
||||
[TYPE_APPLICATION]: {
|
||||
label: messages.website,
|
||||
component: WebsiteType,
|
||||
},
|
||||
[TYPE_MINECRAFT_SERVER]: {
|
||||
label: messages.minecraftServer,
|
||||
component: MinecraftServerType,
|
||||
},
|
||||
};
|
||||
|
||||
const typeToLabel: {
|
||||
[key: ApplicationType]: MessageDescriptor,
|
||||
[key: ApplicationType]: MessageDescriptor,
|
||||
} = Object.keys(typeToForm).reduce((result, key: ApplicationType) => {
|
||||
result[key] = typeToForm[key].label;
|
||||
result[key] = typeToForm[key].label;
|
||||
|
||||
return result;
|
||||
return result;
|
||||
}, {});
|
||||
|
||||
export default class ApplicationForm extends Component<{
|
||||
app: OauthAppResponse,
|
||||
form: FormModel,
|
||||
displayTypeSwitcher?: bool,
|
||||
type: ?ApplicationType,
|
||||
setType: (ApplicationType) => void,
|
||||
onSubmit: (FormModel) => Promise<void>,
|
||||
app: OauthAppResponse,
|
||||
form: FormModel,
|
||||
displayTypeSwitcher?: boolean,
|
||||
type: ?ApplicationType,
|
||||
setType: ApplicationType => void,
|
||||
onSubmit: FormModel => Promise<void>,
|
||||
}> {
|
||||
static defaultProps = {
|
||||
setType: () => {},
|
||||
};
|
||||
static defaultProps = {
|
||||
setType: () => {},
|
||||
};
|
||||
|
||||
render() {
|
||||
const { type, setType, form, displayTypeSwitcher, app } = this.props;
|
||||
const { component: FormComponent } = type && typeToForm[type] || {};
|
||||
const isUpdate = app.clientId !== '';
|
||||
render() {
|
||||
const { type, setType, form, displayTypeSwitcher, app } = this.props;
|
||||
const { component: FormComponent } = (type && typeToForm[type]) || {};
|
||||
const isUpdate = app.clientId !== '';
|
||||
|
||||
return (
|
||||
<Form form={form} onSubmit={this.onFormSubmit}>
|
||||
<div className={styles.contentWithBackButton}>
|
||||
<BackButton to="/dev/applications" />
|
||||
return (
|
||||
<Form form={form} onSubmit={this.onFormSubmit}>
|
||||
<div className={styles.contentWithBackButton}>
|
||||
<BackButton to="/dev/applications" />
|
||||
|
||||
<div className={styles.form}>
|
||||
<div className={styles.formBody}>
|
||||
<Message {...(isUpdate ? messages.updatingApplication : messages.creatingApplication)}>
|
||||
{(pageTitle: string) => (
|
||||
<h3 className={styles.title}>
|
||||
<Helmet title={pageTitle} />
|
||||
{pageTitle}
|
||||
</h3>
|
||||
)}
|
||||
</Message>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.formBody}>
|
||||
<Message
|
||||
{...(isUpdate
|
||||
? messages.updatingApplication
|
||||
: messages.creatingApplication)}
|
||||
>
|
||||
{(pageTitle: string) => (
|
||||
<h3 className={styles.title}>
|
||||
<Helmet title={pageTitle} />
|
||||
{pageTitle}
|
||||
</h3>
|
||||
)}
|
||||
</Message>
|
||||
|
||||
{displayTypeSwitcher && (
|
||||
<div className={styles.formRow}>
|
||||
<ApplicationTypeSwitcher
|
||||
selectedType={type}
|
||||
setType={setType}
|
||||
appTypes={typeToLabel}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{FormComponent ? (
|
||||
<FormComponent form={form} app={app} />
|
||||
) : (
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.toDisplayRegistrationFormChooseType} />
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!!FormComponent && (
|
||||
<Button
|
||||
color={COLOR_GREEN}
|
||||
block
|
||||
label={isUpdate ? messages.updateApplication : messages.createApplication}
|
||||
type="submit"
|
||||
/>
|
||||
)}
|
||||
{displayTypeSwitcher && (
|
||||
<div className={styles.formRow}>
|
||||
<ApplicationTypeSwitcher
|
||||
selectedType={type}
|
||||
setType={setType}
|
||||
appTypes={typeToLabel}
|
||||
/>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
)}
|
||||
|
||||
{FormComponent ? (
|
||||
<FormComponent form={form} app={app} />
|
||||
) : (
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message
|
||||
{...messages.toDisplayRegistrationFormChooseType}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!!FormComponent && (
|
||||
<Button
|
||||
color={COLOR_GREEN}
|
||||
block
|
||||
label={
|
||||
isUpdate
|
||||
? messages.updateApplication
|
||||
: messages.createApplication
|
||||
}
|
||||
type="submit"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
onFormSubmit = async () => {
|
||||
const { form } = this.props;
|
||||
|
||||
try {
|
||||
await this.props.onSubmit(form);
|
||||
} catch (resp) {
|
||||
if (resp.errors) {
|
||||
form.setErrors(resp.errors);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.unexpected(new Error('Error submitting application form'), resp);
|
||||
}
|
||||
|
||||
onFormSubmit = async () => {
|
||||
const { form } = this.props;
|
||||
|
||||
try {
|
||||
await this.props.onSubmit(form);
|
||||
} catch (resp) {
|
||||
if (resp.errors) {
|
||||
form.setErrors(resp.errors);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.unexpected(new Error('Error submitting application form'), resp);
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
|
@@ -7,26 +7,30 @@ import { Radio } from 'components/ui/form';
|
||||
|
||||
import styles from './applicationTypeSwitcher.scss';
|
||||
|
||||
export default function ApplicationTypeSwitcher({ setType, appTypes, selectedType }: {
|
||||
appTypes: {
|
||||
[key: ApplicationType]: MessageDescriptor,
|
||||
},
|
||||
selectedType: ?ApplicationType,
|
||||
setType: (type: ApplicationType) => void,
|
||||
export default function ApplicationTypeSwitcher({
|
||||
setType,
|
||||
appTypes,
|
||||
selectedType,
|
||||
}: {
|
||||
appTypes: {
|
||||
[key: ApplicationType]: MessageDescriptor,
|
||||
},
|
||||
selectedType: ?ApplicationType,
|
||||
setType: (type: ApplicationType) => void,
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
{Object.keys(appTypes).map((type: ApplicationType) => (
|
||||
<div className={styles.radioContainer} key={type}>
|
||||
<Radio
|
||||
onChange={() => setType(type)}
|
||||
skin={SKIN_LIGHT}
|
||||
label={appTypes[type]}
|
||||
value={type}
|
||||
checked={selectedType === type}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
return (
|
||||
<div>
|
||||
{Object.keys(appTypes).map((type: ApplicationType) => (
|
||||
<div className={styles.radioContainer} key={type}>
|
||||
<Radio
|
||||
onChange={() => setType(type)}
|
||||
skin={SKIN_LIGHT}
|
||||
label={appTypes[type]}
|
||||
value={type}
|
||||
checked={selectedType === type}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
// @flow
|
||||
import type {OauthAppResponse} from 'services/api/oauth';
|
||||
import type { OauthAppResponse } from 'services/api/oauth';
|
||||
import React from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import styles from 'components/profile/profileForm.scss';
|
||||
@@ -8,46 +8,52 @@ import { SKIN_LIGHT } from 'components/ui';
|
||||
|
||||
import messages from './ApplicationForm.intl.json';
|
||||
|
||||
export default function MinecraftServerType({ form, app }: {
|
||||
form: FormModel,
|
||||
app: OauthAppResponse,
|
||||
export default function MinecraftServerType({
|
||||
form,
|
||||
app,
|
||||
}: {
|
||||
form: FormModel,
|
||||
app: OauthAppResponse,
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.formRow}>
|
||||
<Input {...form.bindField('name')}
|
||||
label={messages.serverName}
|
||||
defaultValue={app.name}
|
||||
required
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...form.bindField('name')}
|
||||
label={messages.serverName}
|
||||
defaultValue={app.name}
|
||||
required
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.ipAddressIsOptionButPreferable} />
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<Input {...form.bindField('minecraftServerIp')}
|
||||
label={messages.serverIp}
|
||||
defaultValue={app.minecraftServerIp}
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.ipAddressIsOptionButPreferable} />
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...form.bindField('minecraftServerIp')}
|
||||
label={messages.serverIp}
|
||||
defaultValue={app.minecraftServerIp}
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.youCanAlsoSpecifyServerSite} />
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<Input {...form.bindField('websiteUrl')}
|
||||
label={messages.websiteLink}
|
||||
defaultValue={app.websiteUrl}
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message {...messages.youCanAlsoSpecifyServerSite} />
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
{...form.bindField('websiteUrl')}
|
||||
label={messages.websiteLink}
|
||||
defaultValue={app.websiteUrl}
|
||||
skin={SKIN_LIGHT}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user