diff --git a/package.json b/package.json
index 0f4c716..f78bdea 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"up": "npm update",
"test": "karma start ./karma.conf.js",
"lint": "eslint ./src",
- "i18n": "cd ./scripts && ./node_modules/.bin/babel-node i18n-collect.js",
+ "i18n": "babel-node ./scripts/i18n-collect.js",
"build": "rm -rf dist/ && webpack --progress --colors -p"
},
"dependencies": {
@@ -33,6 +33,7 @@
"react-router": "^2.0.0",
"react-router-redux": "^3.0.0",
"redux": "^3.0.4",
+ "redux-localstorage": "^0.4.1",
"redux-thunk": "^2.0.0",
"webfontloader": "^1.6.26",
"whatwg-fetch": "^1.0.0"
@@ -50,6 +51,7 @@
"babel-preset-stage-0": "^6.3.13",
"babel-runtime": "^6.0.0",
"bundle-loader": "^0.5.4",
+ "circular-dependency-plugin": "^2.0.0",
"css-loader": "^0.23.0",
"enzyme": "^2.2.0",
"eslint": "^3.1.1",
diff --git a/scripts/i18n-collect.js b/scripts/i18n-collect.js
index 6a4657c..ab8a0e6 100644
--- a/scripts/i18n-collect.js
+++ b/scripts/i18n-collect.js
@@ -5,8 +5,8 @@ import {sync as mkdirpSync} from 'mkdirp';
import chalk from 'chalk';
import prompt from 'prompt';
-const MESSAGES_PATTERN = '../dist/messages/**/*.json';
-const LANG_DIR = '../src/i18n';
+const MESSAGES_PATTERN = `${__dirname}/../dist/messages/**/*.json`;
+const LANG_DIR = `${__dirname}/../src/i18n`;
const DEFAULT_LOCALE = 'en';
const SUPPORTED_LANGS = [DEFAULT_LOCALE].concat('ru', 'be', 'uk');
diff --git a/src/components/accounts/AccountSwitcher.intl.json b/src/components/accounts/AccountSwitcher.intl.json
new file mode 100644
index 0000000..e2ceb9c
--- /dev/null
+++ b/src/components/accounts/AccountSwitcher.intl.json
@@ -0,0 +1,5 @@
+{
+ "addAccount": "Add account",
+ "goToEly": "Go to Ely.by profile",
+ "logout": "Log out"
+}
diff --git a/src/components/accounts/AccountSwitcher.jsx b/src/components/accounts/AccountSwitcher.jsx
new file mode 100644
index 0000000..3ce1663
--- /dev/null
+++ b/src/components/accounts/AccountSwitcher.jsx
@@ -0,0 +1,167 @@
+import React, { Component, PropTypes } from 'react';
+
+import classNames from 'classnames';
+import { Link } from 'react-router';
+import { FormattedMessage as Message } from 'react-intl';
+
+import loader from 'services/loader';
+import { skins, SKIN_DARK, COLOR_WHITE } from 'components/ui';
+import { Button } from 'components/ui/form';
+
+import styles from './accountSwitcher.scss';
+import messages from './AccountSwitcher.intl.json';
+
+export class AccountSwitcher extends Component {
+ 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.shape({ // TODO: accounts shape
+ active: PropTypes.shape({
+ id: PropTypes.number
+ }),
+ available: PropTypes.arrayOf(PropTypes.shape({
+ id: PropTypes.number
+ }))
+ }),
+ 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() {}
+ };
+
+ render() {
+ const { accounts, skin, allowAdd, allowLogout, highlightActiveAccount } = this.props;
+
+ let {available} = accounts;
+
+ if (highlightActiveAccount) {
+ available = available.filter((account) => account.id !== accounts.active.id);
+ }
+
+ return (
+
+ {highlightActiveAccount ? (
+
+
+
+
+ {accounts.active.username}
+
+
+ {accounts.active.email}
+
+
+
+
+ ) : null}
+ {available.map((account, id) => (
+
+
+
+ {allowLogout ? (
+
+ ) : (
+
+ )}
+
+
+
+ {account.username}
+
+
+ {account.email}
+
+
+
+ ))}
+ {allowAdd ? (
+
+
+ );
+ }
+
+ onSwitch = (account) => (event) => {
+ event.preventDefault();
+
+ loader.show();
+
+ this.props.switchAccount(account)
+ .then(() => this.props.onAfterAction())
+ .then(() => this.props.onSwitch(account))
+ .finally(() => loader.hide());
+ };
+
+ onRemove = (account) => (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ this.props.removeAccount(account)
+ .then(() => this.props.onAfterAction());
+ };
+}
+
+import { connect } from 'react-redux';
+import { authenticate, revoke } from 'components/accounts/actions';
+
+export default connect(({accounts, user}) => ({
+ accounts,
+ userLang: user.lang // this is to force re-render on lang change
+}), {
+ switchAccount: authenticate,
+ removeAccount: revoke
+})(AccountSwitcher);
diff --git a/src/components/accounts/accountSwitcher.scss b/src/components/accounts/accountSwitcher.scss
new file mode 100644
index 0000000..dc3d08c
--- /dev/null
+++ b/src/components/accounts/accountSwitcher.scss
@@ -0,0 +1,238 @@
+@import '~components/ui/colors.scss';
+@import '~components/ui/fonts.scss';
+
+// TODO: эту константу можно заимпортить из panel.scss, но это приводит к странным ошибкам
+//@import '~components/ui/panel.scss';
+$bodyLeftRightPadding: 20px;
+
+$lightBorderColor: #eee;
+
+.accountSwitcher {
+ text-align: left;
+}
+
+.accountInfo {
+
+}
+
+.accountUsername,
+.accountEmail {
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.lightAccountSwitcher {
+ background: #fff;
+ color: #444;
+ width: 205px;
+
+ $border: 1px solid $lightBorderColor;
+ border-left: $border;
+ border-right: $border;
+ border-bottom: 7px solid darker($green);
+
+ .item {
+ padding: 15px;
+ border-bottom: 1px solid $lightBorderColor;
+ }
+
+ .accountSwitchItem {
+ cursor: pointer;
+ transition: .25s;
+
+ &:hover {
+ background-color: $whiteButtonLight;
+ }
+
+ &:active {
+ background-color: $whiteButtonDark;
+ }
+ }
+
+ .accountIcon {
+ font-size: 27px;
+ width: 20px;
+ text-align: center;
+ }
+
+ .activeAccountIcon {
+ font-size: 40px;
+ }
+
+ .activeAccountInfo {
+ margin-left: 29px;
+ }
+
+ .activeAccountUsername {
+ font-family: $font-family-title;
+ font-size: 20px;
+ color: $green;
+ }
+
+ .activeAccountEmail {
+ }
+
+ .links {
+ margin-top: 6px;
+ }
+
+ .link {
+ font-size: 12px;
+ margin-bottom: 3px;
+
+ &:last-of-type {
+ margin-bottom: 0;
+ }
+
+ a {
+ color: #666;
+ font-size: 12px;
+ border-bottom: 1px dotted #666;
+ text-decoration: none;
+ transition: .25s;
+
+ &:hover {
+ border-bottom-color: #aaa;
+ color: #777;
+ }
+ }
+ }
+
+ .accountInfo {
+ margin-left: 29px;
+ margin-right: 25px;
+ }
+
+ .accountUsername {
+ font-family: $font-family-title;
+ font-size: 14px;
+ color: #666;
+ }
+
+ .accountEmail {
+ font-size: 10px;
+ color: #999;
+ }
+
+ .addAccount {
+ }
+}
+
+.darkAccountSwitcher {
+ background: $black;
+
+ $border: 1px solid lighter($black);
+
+ .item {
+ padding: 15px 20px;
+ border-top: 1px solid lighter($black);
+ transition: .25s;
+ cursor: pointer;
+
+ &:hover {
+ background-color: lighter($black);
+ }
+
+ &:active {
+ background-color: darker($black);
+ }
+
+ &:last-of-type {
+ border-bottom: $border;
+ }
+ }
+
+ .accountIcon {
+ font-size: 35px;
+ }
+
+ .accountInfo {
+ margin-left: 30px;
+ margin-right: 26px;
+ }
+
+ .accountUsername {
+ font-family: $font-family-title;
+ color: #fff;
+ }
+
+ .accountEmail {
+ color: #666;
+ font-size: 12px;
+ }
+}
+
+.accountIcon {
+ composes: minecraft-character from 'components/ui/icons.scss';
+
+ float: left;
+
+ &1 {
+ color: $green;
+ }
+
+ &2 {
+ color: $blue;
+ }
+
+ &3 {
+ color: $violet;
+ }
+
+ &4 {
+ color: $orange;
+ }
+
+ &5 {
+ color: $dark_blue;
+ }
+
+ &6 {
+ color: $light_violet;
+ }
+
+ &7 {
+ color: $red;
+ }
+}
+
+.addIcon {
+ composes: plus from 'components/ui/icons.scss';
+
+ color: $green;
+ position: relative;
+ bottom: 1px;
+ margin-right: 3px;
+}
+
+.nextIcon {
+ composes: arrowRight from 'components/ui/icons.scss';
+
+ position: relative;
+ float: right;
+
+ font-size: 24px;
+ color: #4E4E4E;
+ line-height: 35px;
+ left: 0;
+
+ transition: color .25s, left .5s;
+
+ .item:hover & {
+ color: #aaa;
+ left: 5px;
+ }
+}
+
+.logoutIcon {
+ composes: exit from 'components/ui/icons.scss';
+
+ color: #cdcdcd;
+ float: right;
+ line-height: 27px;
+ transition: .25s;
+
+ &:hover {
+ color: #777;
+ }
+}
diff --git a/src/components/accounts/actions.js b/src/components/accounts/actions.js
new file mode 100644
index 0000000..37fe539
--- /dev/null
+++ b/src/components/accounts/actions.js
@@ -0,0 +1,149 @@
+import authentication from 'services/api/authentication';
+import accounts from 'services/api/accounts';
+import { updateUser, logout } from 'components/user/actions';
+import { setLocale } from 'components/i18n/actions';
+
+/**
+ * @typedef {object} Account
+ * @property {string} account.id
+ * @property {string} account.username
+ * @property {string} account.email
+ * @property {string} account.token
+ * @property {string} account.refreshToken
+ */
+
+/**
+ * @param {Account|object} account
+ * @param {string} account.token
+ * @param {string} account.refreshToken
+ *
+ * @return {function}
+ */
+export function authenticate({token, refreshToken}) {
+ return (dispatch) =>
+ authentication.validateToken({token, refreshToken})
+ .then(({token, refreshToken}) =>
+ accounts.current({token})
+ .then((user) => ({
+ user,
+ account: {
+ id: user.id,
+ username: user.username,
+ email: user.email,
+ token,
+ refreshToken
+ }
+ }))
+ )
+ .then(({user, account}) => {
+ dispatch(add(account));
+ dispatch(activate(account));
+ dispatch(updateUser({
+ isGuest: false,
+ ...user
+ }));
+
+ return dispatch(setLocale(user.lang))
+ .then(() => account);
+ });
+}
+
+/**
+ * @param {Account} account
+ *
+ * @return {function}
+ */
+export function revoke(account) {
+ return (dispatch, getState) => {
+ const accountToReplace = getState().accounts.available.find(({id}) => id !== account.id);
+
+ if (accountToReplace) {
+ return dispatch(authenticate(accountToReplace))
+ .then(() => {
+ authentication.logout(account);
+ dispatch(remove(account));
+ });
+ }
+
+ return dispatch(logout());
+ };
+}
+
+export const ADD = 'accounts:add';
+/**
+ * @api private
+ *
+ * @param {Account} account
+ *
+ * @return {object} - action definition
+ */
+export function add(account) {
+ return {
+ type: ADD,
+ payload: account
+ };
+}
+
+export const REMOVE = 'accounts:remove';
+/**
+ * @api private
+ *
+ * @param {Account} account
+ *
+ * @return {object} - action definition
+ */
+export function remove(account) {
+ return {
+ type: REMOVE,
+ payload: account
+ };
+}
+
+export const ACTIVATE = 'accounts:activate';
+/**
+ * @api private
+ *
+ * @param {Account} account
+ *
+ * @return {object} - action definition
+ */
+export function activate(account) {
+ return {
+ type: ACTIVATE,
+ payload: account
+ };
+}
+
+export function logoutAll() {
+ return (dispatch, getState) => {
+ const {accounts: {available}} = getState();
+
+ available.forEach((account) => authentication.logout(account));
+
+ dispatch(reset());
+ };
+}
+export const RESET = 'accounts:reset';
+/**
+ * @api private
+ *
+ * @return {object} - action definition
+ */
+export function reset() {
+ return {
+ type: RESET
+ };
+}
+
+export const UPDATE_TOKEN = 'accounts:updateToken';
+/**
+ * @param {string} token
+ *
+ * @return {object} - action definition
+ */
+export function updateToken(token) {
+ return {
+ type: UPDATE_TOKEN,
+ payload: token
+ };
+}
diff --git a/src/components/accounts/index.js b/src/components/accounts/index.js
new file mode 100644
index 0000000..d74b7fa
--- /dev/null
+++ b/src/components/accounts/index.js
@@ -0,0 +1 @@
+export AccountSwitcher from './AccountSwitcher';
diff --git a/src/components/accounts/reducer.js b/src/components/accounts/reducer.js
new file mode 100644
index 0000000..36d6eb4
--- /dev/null
+++ b/src/components/accounts/reducer.js
@@ -0,0 +1,82 @@
+import { ADD, REMOVE, ACTIVATE, RESET, UPDATE_TOKEN } from './actions';
+
+/**
+ * @typedef {AccountsState}
+ * @property {Account} active
+ * @property {Account[]} available
+ */
+
+/**
+ * @param {AccountsState} state
+ * @param {string} options.type
+ * @param {object} options.payload
+ *
+ * @return {AccountsState}
+ */
+export default function accounts(
+ state = {
+ active: null,
+ available: []
+ },
+ {type, payload = {}}
+) {
+ switch (type) {
+ case ADD:
+ if (!payload || !payload.id || !payload.token || !payload.refreshToken) {
+ throw new Error('Invalid or empty payload passed for accounts.add');
+ }
+
+ state.available = state.available
+ .filter((account) => account.id !== payload.id)
+ .concat(payload);
+
+ state.available.sort((account1, account2) => {
+ if (account1.username === account2.username) {
+ return 0;
+ }
+
+ return account1.username > account2.username ? 1 : -1;
+ });
+
+ return state;
+
+ case ACTIVATE:
+ if (!payload || !payload.id || !payload.token || !payload.refreshToken) {
+ throw new Error('Invalid or empty payload passed for accounts.add');
+ }
+
+ return {
+ ...state,
+ active: payload
+ };
+
+ case RESET:
+ return accounts(undefined, {});
+
+ case REMOVE:
+ if (!payload || !payload.id) {
+ throw new Error('Invalid or empty payload passed for accounts.remove');
+ }
+
+ return {
+ ...state,
+ available: state.available.filter((account) => account.id !== payload.id)
+ };
+
+ case UPDATE_TOKEN:
+ if (typeof payload !== 'string') {
+ throw new Error('payload must be a jwt token');
+ }
+
+ return {
+ ...state,
+ active: {
+ ...state.active,
+ token: payload
+ }
+ };
+
+ default:
+ return state;
+ }
+}
diff --git a/src/components/auth/PanelTransition.jsx b/src/components/auth/PanelTransition.jsx
index 0544005..f493455 100644
--- a/src/components/auth/PanelTransition.jsx
+++ b/src/components/auth/PanelTransition.jsx
@@ -33,7 +33,7 @@ const contexts = [
['login', 'password', 'forgotPassword', 'recoverPassword'],
['register', 'activation', 'resendActivation'],
['acceptRules'],
- ['permissions']
+ ['chooseAccount', 'permissions']
];
if (process.env.NODE_ENV !== 'production') {
@@ -64,10 +64,7 @@ class PanelTransition extends Component {
payload: PropTypes.object
})]),
isLoading: PropTypes.bool,
- login: PropTypes.shape({
- login: PropTypes.string,
- password: PropTypes.string
- })
+ login: PropTypes.string
}).isRequired,
user: userShape.isRequired,
setErrors: PropTypes.func.isRequired,
@@ -89,12 +86,12 @@ class PanelTransition extends Component {
type: PropTypes.string,
payload: PropTypes.object
})]),
- login: PropTypes.shape({
- login: PropTypes.string,
- password: PropTypes.string
- })
+ login: PropTypes.string
}),
user: userShape,
+ accounts: PropTypes.shape({
+ available: PropTypes.array
+ }),
requestRedraw: PropTypes.func,
clearErrors: PropTypes.func,
resolve: PropTypes.func,
@@ -314,7 +311,12 @@ class PanelTransition extends Component {
}
shouldMeasureHeight() {
- return [this.props.auth.error, this.state.isHeightDirty, this.props.user.lang].join('');
+ return [
+ this.props.auth.error,
+ this.state.isHeightDirty,
+ this.props.user.lang,
+ this.props.accounts.available.length
+ ].join('');
}
getHeader({key, style, data}) {
@@ -446,12 +448,35 @@ class PanelTransition extends Component {
}
}
-export default connect((state) => ({
- user: state.user,
- auth: state.auth,
- resolve: authFlow.resolve.bind(authFlow),
- reject: authFlow.reject.bind(authFlow)
-}), {
+export default connect((state) => {
+ const {login} = state.auth;
+ let user = {
+ ...state.user
+ };
+
+ if (login) {
+ user = {
+ ...user,
+ isGuest: true,
+ email: '',
+ username: ''
+ };
+
+ if (/[@.]/.test(login)) {
+ user.email = login;
+ } else {
+ user.username = login;
+ }
+ }
+
+ return {
+ user,
+ accounts: state.accounts, // need this, to re-render height
+ auth: state.auth,
+ resolve: authFlow.resolve.bind(authFlow),
+ reject: authFlow.reject.bind(authFlow)
+ };
+}, {
clearErrors: actions.clearErrors,
setErrors: actions.setErrors
})(PanelTransition);
diff --git a/src/components/auth/README.md b/src/components/auth/README.md
index f1447fc..7a8142a 100644
--- a/src/components/auth/README.md
+++ b/src/components/auth/README.md
@@ -2,12 +2,12 @@
To add new panel you need to:
-* 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`
* create panel component at `components/auth/[panelId]`
* add new context in `components/auth/PanelTransition`
* connect component to `routes`
+* 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: f4d315c
diff --git a/src/components/auth/actions.js b/src/components/auth/actions.js
index 442db44..fb91af0 100644
--- a/src/components/auth/actions.js
+++ b/src/components/auth/actions.js
@@ -1,6 +1,7 @@
import { routeActions } from 'react-router-redux';
-import { updateUser, logout as logoutUser, acceptRules as userAcceptRules, authenticate } from 'components/user/actions';
+import { updateUser, logout, acceptRules as userAcceptRules } from 'components/user/actions';
+import { authenticate } from 'components/accounts/actions';
import authentication from 'services/api/authentication';
import oauth from 'services/api/oauth';
import signup from 'services/api/signup';
@@ -19,24 +20,13 @@ export function login({login = '', password = '', rememberMe = false}) {
.catch((resp) => {
if (resp.errors) {
if (resp.errors.password === PASSWORD_REQUIRED) {
- let username = '';
- let email = '';
-
- if (/[@.]/.test(login)) {
- email = login;
- } else {
- username = login;
- }
-
- return dispatch(updateUser({
- username,
- email
- }));
+ return dispatch(setLogin(login));
} else if (resp.errors.login === ACTIVATION_REQUIRED) {
return dispatch(needActivation());
} else if (resp.errors.login === LOGIN_REQUIRED && password) {
+ // TODO: log this case to backend
// return to the first step
- dispatch(logout());
+ return dispatch(logout());
}
}
@@ -125,7 +115,23 @@ export function resendActivation({email = '', captcha}) {
);
}
-export const ERROR = 'error';
+export const SET_LOGIN = 'auth:setLogin';
+export function setLogin(login) {
+ return {
+ type: SET_LOGIN,
+ payload: login
+ };
+}
+
+export const SET_SWITCHER = 'auth:setAccountSwitcher';
+export function setAccountSwitcher(isOn) {
+ return {
+ type: SET_SWITCHER,
+ payload: isOn
+ };
+}
+
+export const ERROR = 'auth:error';
export function setErrors(errors) {
return {
type: ERROR,
@@ -138,9 +144,8 @@ export function clearErrors() {
return setErrors(null);
}
-export function logout() {
- return logoutUser();
-}
+export { logout, updateUser } from 'components/user/actions';
+export { authenticate } from 'components/accounts/actions';
/**
* @param {object} oauthData
@@ -149,6 +154,13 @@ export function logout() {
* @param {string} oauthData.responseType
* @param {string} oauthData.description
* @param {string} oauthData.scope
+ * @param {string} [oauthData.prompt='none'] - comma-separated list of values to adjust auth flow
+ * Posible values:
+ * * none - default behaviour
+ * * consent - forcibly prompt user for rules acceptance
+ * * select_account - force account choosage, even if user has only one
+ * @param {string} oauthData.loginHint - allows to choose the account, which will be used for auth
+ * The possible values: account id, email, username
* @param {string} oauthData.state
*
* @return {Promise}
@@ -159,8 +171,17 @@ export function oAuthValidate(oauthData) {
return wrapInLoader((dispatch) =>
oauth.validate(oauthData)
.then((resp) => {
+ let prompt = (oauthData.prompt || 'none').split(',').map((item) => item.trim);
+ if (prompt.includes('none')) {
+ prompt = ['none'];
+ }
+
dispatch(setClient(resp.client));
- dispatch(setOAuthRequest(resp.oAuth));
+ dispatch(setOAuthRequest({
+ ...resp.oAuth,
+ prompt: oauthData.prompt || 'none',
+ loginHint: oauthData.loginHint
+ }));
dispatch(setScopes(resp.session.scopes));
localStorage.setItem('oauthData', JSON.stringify({ // @see services/authFlow/AuthFlow
timestamp: Date.now(),
@@ -226,6 +247,13 @@ export function setClient({id, name, description}) {
};
}
+export function resetOAuth() {
+ return (dispatch) => {
+ localStorage.removeItem('oauthData');
+ dispatch(setOAuthRequest({}));
+ };
+}
+
export const SET_OAUTH = 'set_oauth';
export function setOAuthRequest(oauth) {
return {
@@ -235,6 +263,8 @@ export function setOAuthRequest(oauth) {
redirectUrl: oauth.redirect_uri,
responseType: oauth.response_type,
scope: oauth.scope,
+ prompt: oauth.prompt,
+ loginHint: oauth.loginHint,
state: oauth.state
}
};
@@ -305,7 +335,14 @@ function needActivation() {
}
function authHandler(dispatch) {
- return (resp) => dispatch(authenticate(resp.access_token, resp.refresh_token));
+ return (resp) => dispatch(authenticate({
+ token: resp.access_token,
+ refreshToken: resp.refresh_token
+ })).then((resp) => {
+ dispatch(setLogin(null));
+
+ return resp;
+ });
}
function validationErrorsHandler(dispatch, repeatUrl) {
diff --git a/src/components/auth/chooseAccount/ChooseAccount.intl.json b/src/components/auth/chooseAccount/ChooseAccount.intl.json
new file mode 100644
index 0000000..9b207ca
--- /dev/null
+++ b/src/components/auth/chooseAccount/ChooseAccount.intl.json
@@ -0,0 +1,6 @@
+{
+ "chooseAccountTitle": "Choose an account",
+ "addAccount": "Log into another account",
+ "logoutAll": "Log out from all accounts",
+ "description": "You have logged in into multiple accounts. Please choose the one, you want to use to authorize {appName}"
+}
diff --git a/src/components/auth/chooseAccount/ChooseAccount.jsx b/src/components/auth/chooseAccount/ChooseAccount.jsx
new file mode 100644
index 0000000..0aaa6de
--- /dev/null
+++ b/src/components/auth/chooseAccount/ChooseAccount.jsx
@@ -0,0 +1,16 @@
+import factory from 'components/auth/factory';
+import messages from './ChooseAccount.intl.json';
+import Body from './ChooseAccountBody';
+
+export default factory({
+ title: messages.chooseAccountTitle,
+ body: Body,
+ footer: {
+ label: messages.addAccount
+ },
+ links: [
+ {
+ label: messages.logoutAll
+ }
+ ]
+});
diff --git a/src/components/auth/chooseAccount/ChooseAccountBody.jsx b/src/components/auth/chooseAccount/ChooseAccountBody.jsx
new file mode 100644
index 0000000..b80ba1b
--- /dev/null
+++ b/src/components/auth/chooseAccount/ChooseAccountBody.jsx
@@ -0,0 +1,43 @@
+import React from 'react';
+
+import { FormattedMessage as Message } from 'react-intl';
+
+import BaseAuthBody from 'components/auth/BaseAuthBody';
+import { AccountSwitcher } from 'components/accounts';
+
+import styles from './chooseAccount.scss';
+import messages from './ChooseAccount.intl.json';
+
+export default class ChooseAccountBody extends BaseAuthBody {
+ static displayName = 'ChooseAccountBody';
+ static panelId = 'chooseAccount';
+
+ render() {
+ const {client} = this.context.auth;
+
+ return (
+
+ {this.renderErrors()}
+
+
+ {client.name}
+ }} />
+
+
+
+
+ );
+ }
+
+ onSwitch = (account) => {
+ this.context.resolve(account);
+ };
+}
diff --git a/src/components/auth/chooseAccount/chooseAccount.scss b/src/components/auth/chooseAccount/chooseAccount.scss
new file mode 100644
index 0000000..be1319a
--- /dev/null
+++ b/src/components/auth/chooseAccount/chooseAccount.scss
@@ -0,0 +1,23 @@
+//@import '~components/ui/panel.scss';
+// TODO: эту константу можно заимпортить из panel.scss, но это приводит к странным ошибкам
+$bodyLeftRightPadding: 20px;
+
+//@import '~components/ui/fonts.scss';
+// TODO: эту константу можно заимпортить из fonts.scss, но это приводит к странным ошибкам
+$font-family-title: 'Roboto Condensed', Arial, sans-serif;
+
+.accountSwitcherContainer {
+ margin-left: -$bodyLeftRightPadding;
+ margin-right: -$bodyLeftRightPadding;
+}
+
+.description {
+ font-family: $font-family-title;
+ margin: 5px 0 19px;
+ line-height: 1.4;
+ font-size: 16px;
+}
+
+.appName {
+ color: #fff;
+}
diff --git a/src/components/auth/reducer.js b/src/components/auth/reducer.js
index 029b76b..28d470a 100644
--- a/src/components/auth/reducer.js
+++ b/src/components/auth/reducer.js
@@ -1,10 +1,22 @@
import { combineReducers } from 'redux';
-import { ERROR, SET_CLIENT, SET_OAUTH, SET_OAUTH_RESULT, SET_SCOPES, SET_LOADING_STATE, REQUIRE_PERMISSIONS_ACCEPT } from './actions';
+import {
+ ERROR,
+ SET_CLIENT,
+ SET_OAUTH,
+ SET_OAUTH_RESULT,
+ SET_SCOPES,
+ SET_LOADING_STATE,
+ REQUIRE_PERMISSIONS_ACCEPT,
+ SET_LOGIN,
+ SET_SWITCHER
+} from './actions';
export default combineReducers({
+ login,
error,
isLoading,
+ isSwitcherEnabled,
client,
oauth,
scopes
@@ -19,6 +31,7 @@ function error(
if (!error) {
throw new Error('Expected payload with error');
}
+
return payload;
default:
@@ -26,6 +39,39 @@ function error(
}
}
+function login(
+ state = null,
+ {type, payload = null}
+) {
+ switch (type) {
+ case SET_LOGIN:
+ if (payload !== null && typeof payload !== 'string') {
+ throw new Error('Expected payload with login string or null');
+ }
+
+ return payload;
+
+ default:
+ 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');
+ }
+
+ return payload;
+
+ default:
+ return state;
+ }
+}
function isLoading(
state = false,
@@ -68,6 +114,8 @@ function oauth(
redirectUrl: payload.redirectUrl,
responseType: payload.responseType,
scope: payload.scope,
+ prompt: payload.prompt,
+ loginHint: payload.loginHint,
state: payload.state
};
diff --git a/src/components/i18n/actions.js b/src/components/i18n/actions.js
index 5b532d4..9869ffa 100644
--- a/src/components/i18n/actions.js
+++ b/src/components/i18n/actions.js
@@ -1,18 +1,26 @@
import i18n from 'services/i18n';
+import captcha from 'services/captcha';
-export const SET_LOCALE = 'SET_LOCALE';
+export const SET_LOCALE = 'i18n:setLocale';
export function setLocale(locale) {
return (dispatch) => i18n.require(
i18n.detectLanguage(locale)
).then(({locale, messages}) => {
- dispatch({
- type: SET_LOCALE,
- payload: {
- locale,
- messages
- }
- });
+ dispatch(_setLocale({locale, messages}));
+
+ // TODO: probably should be moved from here, because it is a side effect
+ captcha.setLang(locale);
return locale;
});
}
+
+function _setLocale({locale, messages}) {
+ return {
+ type: SET_LOCALE,
+ payload: {
+ locale,
+ messages
+ }
+ };
+}
diff --git a/src/components/langMenu/langMenu.scss b/src/components/langMenu/langMenu.scss
index 45321d8..da52a96 100644
--- a/src/components/langMenu/langMenu.scss
+++ b/src/components/langMenu/langMenu.scss
@@ -56,7 +56,7 @@
transition: .2s;
&:hover {
- background: #f5f5f5;
+ background: $whiteButtonLight;
color: #262626;
}
diff --git a/src/components/profile/profile.scss b/src/components/profile/profile.scss
index c3502f3..99c0c0e 100644
--- a/src/components/profile/profile.scss
+++ b/src/components/profile/profile.scss
@@ -81,7 +81,7 @@ $formColumnWidth: 416px;
.paramEditIcon {
composes: pencil from 'components/ui/icons.scss';
- color: $light;
+ color: $white;
transition: .4s;
a:hover & {
diff --git a/src/components/ui/button-groups.scss b/src/components/ui/button-groups.scss
index 1e526c2..00f88cb 100644
--- a/src/components/ui/button-groups.scss
+++ b/src/components/ui/button-groups.scss
@@ -3,6 +3,9 @@
}
.item {
+ // TODO: in some cases we do not need overflow hidden
+ // probably, it is better to create a separate class for children, that will
+ // enable overflow hidden and ellipsis
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
diff --git a/src/components/ui/buttons.scss b/src/components/ui/buttons.scss
index 7f4b925..75a968c 100644
--- a/src/components/ui/buttons.scss
+++ b/src/components/ui/buttons.scss
@@ -42,7 +42,6 @@
}
}
-// TODO: не уверен на счёт этого класса. Мб может лучше добавить это как класс-модификатор для .button?
.smallButton {
composes: button;
@@ -52,20 +51,23 @@
line-height: 30px;
}
-.black {
+.white {
composes: button;
- background-color: $black;
+ background-color: #fff;
+ color: #444;
&:hover {
- background-color: $black-button-light;
+ color: #262626;
+ background-color: $whiteButtonLight;
}
&:active {
- background-color: $black-button-dark;
+ background-color: $whiteButtonDark;
}
}
+@include button-theme('black', $black);
@include button-theme('blue', $blue);
@include button-theme('green', $green);
@include button-theme('orange', $orange);
diff --git a/src/components/ui/colors.scss b/src/components/ui/colors.scss
index 8d2cca5..7441506 100644
--- a/src/components/ui/colors.scss
+++ b/src/components/ui/colors.scss
@@ -5,13 +5,13 @@ $violet: #6b5b8c;
$dark_blue: #28555b;
$light_violet: #8b5d79;
$orange: #dd8650;
-$light: #ebe8e1;
+$white: #ebe8e1;
$black: #232323;
$defaultButtonTextColor : #fff;
-$black-button-light: #392f2c;
-$black-button-dark: #1e0b11;
+$whiteButtonLight: #f5f5f5;
+$whiteButtonDark: #f5f5f5; // TODO: найти оптимальный цвет для прожатого состояния
@function darker($color) {
$elyColorsMap : (
diff --git a/src/components/ui/form/Button.jsx b/src/components/ui/form/Button.jsx
index 449d30a..245ca24 100644
--- a/src/components/ui/form/Button.jsx
+++ b/src/components/ui/form/Button.jsx
@@ -19,7 +19,9 @@ export default class Button extends FormComponent {
PropTypes.string
]).isRequired,
block: PropTypes.bool,
- color: PropTypes.oneOf(colors)
+ small: PropTypes.bool,
+ color: PropTypes.oneOf(colors),
+ className: PropTypes.string
};
static defaultProps = {
@@ -27,7 +29,7 @@ export default class Button extends FormComponent {
};
render() {
- const { color, block, small } = this.props;
+ const { color, block, small, className } = this.props;
const props = omit(this.props, Object.keys(Button.propTypes));
@@ -37,7 +39,7 @@ export default class Button extends FormComponent {