Add prettier and re-configure lint according to current best practises

This commit is contained in:
SleepWalker
2019-11-27 11:03:32 +02:00
parent 991031f211
commit d795ec164f
353 changed files with 23198 additions and 21951 deletions

12
.editorconfig Normal file
View File

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

View File

@@ -1,2 +1,4 @@
flow-typed
tests-e2e
dist
dll
node_modules

221
.eslintrc
View File

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

172
.eslintrc.js Normal file
View 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
View File

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

6
.prettierrc Normal file
View File

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

View File

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

View File

@@ -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: [],
},
},
};

View File

@@ -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,
};

View File

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

View File

@@ -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}/../../..`));
},
};

View File

@@ -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,
};
}

View File

@@ -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",

View File

@@ -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'
// }
},
});

View File

@@ -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);
});
});
}

View File

@@ -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 {};
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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);
}

View File

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

View File

@@ -14,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);

View File

@@ -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;
}
}

View File

@@ -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();
};
}

View File

@@ -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 },
]));
});
});
});

View File

@@ -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,
};
}

View File

@@ -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;
}
}

View File

@@ -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,
},
],
},
);
});
});
});

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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

View File

@@ -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,
};

View File

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

View File

@@ -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,
},
});

View File

@@ -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>
);
}
}

View File

@@ -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

View File

@@ -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')]]);
}));
});
});
});

View File

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

View File

@@ -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,
},
});

View File

@@ -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>
);
}
}

View File

@@ -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;
}

View File

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

View File

@@ -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>
);
}
}

View File

@@ -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;
}

View File

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

View File

@@ -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,
};

View File

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

View File

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

View File

@@ -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);
};
}

View File

@@ -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;
}

View File

@@ -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,
});
}

View File

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

View File

@@ -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);

View File

@@ -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;
}

View File

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

View File

@@ -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,
},
});

View File

@@ -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'));
};
}

View File

@@ -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;
}
}

View File

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

View File

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

View File

@@ -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,
},
});

View File

@@ -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>
);
}
}

View File

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

View File

@@ -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,
},
});

View File

@@ -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>
);
}
}

View File

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

View File

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

View File

@@ -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,
},
});

View File

@@ -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>
);
}
}

View File

@@ -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;
}

View File

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

View File

@@ -3,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,
},
});

View File

@@ -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>
);
}
}

View File

@@ -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;
}
}
}

View File

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

View File

@@ -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,
},
});

View File

@@ -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>
);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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,
});
});
});
});

View File

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

View File

@@ -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,
},
],
});

View File

@@ -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>
);
}
}

View File

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

View File

@@ -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,
},
});

View File

@@ -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>
);
}
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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));
});
});
});

View File

@@ -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);

View File

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

View File

@@ -5,81 +5,81 @@
/* Form state */
.contactForm {
composes: popupWrapper from '~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;
}

View File

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

View File

@@ -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>
);
}

View File

@@ -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));
}
};
}

View File

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

View File

@@ -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);
}
};
};
}

View File

@@ -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>
);
}

View File

@@ -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