From 7dd58acedeab57d0d48a37bb955a6a688fc793ba Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sun, 30 Oct 2016 14:12:49 +0200 Subject: [PATCH 01/34] #48: initial logic for multy-accounts actions --- src/components/accounts/actions.js | 97 +++++++++++ src/components/accounts/reducer.js | 58 +++++++ src/components/auth/actions.js | 2 +- src/components/user/User.js | 14 +- .../middlewares/bearerHeaderMiddleware.js | 14 +- .../middlewares/refreshTokenMiddleware.js | 32 +--- src/services/api/accounts.js | 14 +- src/services/api/authentication.js | 18 ++ src/services/request/request.js | 21 ++- tests/components/accounts/actions.test.js | 134 +++++++++++++++ tests/components/accounts/reducer.test.js | 87 ++++++++++ .../bearerHeaderMiddleware.test.js | 36 +++- .../refreshTokenMiddleware.test.js | 161 +++++++++++------- 13 files changed, 572 insertions(+), 116 deletions(-) create mode 100644 src/components/accounts/actions.js create mode 100644 src/components/accounts/reducer.js create mode 100644 tests/components/accounts/actions.test.js create mode 100644 tests/components/accounts/reducer.test.js diff --git a/src/components/accounts/actions.js b/src/components/accounts/actions.js new file mode 100644 index 0000000..7365783 --- /dev/null +++ b/src/components/accounts/actions.js @@ -0,0 +1,97 @@ +import authentication from 'services/api/authentication'; +import accounts from 'services/api/accounts'; +import { updateUser, logout } from 'components/user/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 + */ +export function authenticate({token, refreshToken}) { + return (dispatch) => { + return 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(user)); + + return account; + }); + }; +} + +/** + * @param {Account} account + */ +export function revoke(account) { + return (dispatch, getState) => { + dispatch(remove(account)); + + if (getState().accounts.length) { + return dispatch(authenticate(getState().accounts[0])); + } else { + return dispatch(logout()); + } + }; +} + +export const ADD = 'accounts:add'; +/** + * @api private + * + * @param {Account} account + */ +export function add(account) { + return { + type: ADD, + payload: account + }; +} + +export const REMOVE = 'accounts:remove'; +/** + * @api private + * + * @param {Account} account + */ +export function remove(account) { + return { + type: REMOVE, + payload: account + }; +} + +export const ACTIVATE = 'accounts:activate'; +/** + * @api private + * + * @param {Account} account + */ +export function activate(account) { + return { + type: ACTIVATE, + payload: account + }; +} diff --git a/src/components/accounts/reducer.js b/src/components/accounts/reducer.js new file mode 100644 index 0000000..eea892a --- /dev/null +++ b/src/components/accounts/reducer.js @@ -0,0 +1,58 @@ +import { ADD, REMOVE, ACTIVATE } 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, + {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'); + } + + if (!state.available.some((account) => account.id === payload.id)) { + state.available = state.available.concat(payload); + } + + 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 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) + }; + + default: + return { + active: null, + available: [] + }; + } +} diff --git a/src/components/auth/actions.js b/src/components/auth/actions.js index 442db44..cbe2fce 100644 --- a/src/components/auth/actions.js +++ b/src/components/auth/actions.js @@ -36,7 +36,7 @@ export function login({login = '', password = '', rememberMe = false}) { return dispatch(needActivation()); } else if (resp.errors.login === LOGIN_REQUIRED && password) { // return to the first step - dispatch(logout()); + return dispatch(logout()); } } diff --git a/src/components/user/User.js b/src/components/user/User.js index ff61eb2..568d8b2 100644 --- a/src/components/user/User.js +++ b/src/components/user/User.js @@ -4,7 +4,7 @@ const KEY_USER = 'user'; export default class User { /** - * @param {object|string|undefined} data plain object or jwt token or empty to load from storage + * @param {object} [data] - plain object or jwt token or empty to load from storage * * @return {User} */ @@ -18,8 +18,6 @@ export default class User { const defaults = { id: null, uuid: null, - token: '', - refreshToken: '', username: '', email: '', // will contain user's email or masked email @@ -27,12 +25,18 @@ export default class User { maskedEmail: '', avatar: '', lang: '', - goal: null, // the goal with wich user entered site - isGuest: true, isActive: false, shouldAcceptRules: false, // whether user need to review updated rules passwordChangedAt: null, hasMojangUsernameCollision: false, + + // frontend app specific attributes + isGuest: true, + goal: null, // the goal with wich user entered site + + // TODO: the following does not belongs here. Move it later + token: '', + refreshToken: '', }; const user = Object.keys(defaults).reduce((user, key) => { diff --git a/src/components/user/middlewares/bearerHeaderMiddleware.js b/src/components/user/middlewares/bearerHeaderMiddleware.js index caca14a..cb1c9db 100644 --- a/src/components/user/middlewares/bearerHeaderMiddleware.js +++ b/src/components/user/middlewares/bearerHeaderMiddleware.js @@ -8,14 +8,18 @@ */ export default function bearerHeaderMiddleware({getState}) { return { - before(data) { - const {token} = getState().user; + before(req) { + let {token} = getState().user; - if (token) { - data.options.headers.Authorization = `Bearer ${token}`; + if (req.options.token) { + token = req.options.token; } - return data; + if (token) { + req.options.headers.Authorization = `Bearer ${token}`; + } + + return req; } }; } diff --git a/src/components/user/middlewares/refreshTokenMiddleware.js b/src/components/user/middlewares/refreshTokenMiddleware.js index faeb7ca..9f6cc9c 100644 --- a/src/components/user/middlewares/refreshTokenMiddleware.js +++ b/src/components/user/middlewares/refreshTokenMiddleware.js @@ -12,12 +12,12 @@ import {updateUser, logout} from '../actions'; */ export default function refreshTokenMiddleware({dispatch, getState}) { return { - before(data) { + before(req) { const {refreshToken, token} = getState().user; - const isRefreshTokenRequest = data.url.includes('refresh-token'); + const isRefreshTokenRequest = req.url.includes('refresh-token'); - if (!token || isRefreshTokenRequest) { - return data; + if (!token || isRefreshTokenRequest || req.options.autoRefreshToken === false) { + return req; } try { @@ -25,33 +25,17 @@ export default function refreshTokenMiddleware({dispatch, getState}) { const jwt = getJWTPayload(token); if (jwt.exp - SAFETY_FACTOR < Date.now() / 1000) { - return requestAccessToken(refreshToken, dispatch).then(() => data); + return requestAccessToken(refreshToken, dispatch).then(() => req); } } catch (err) { dispatch(logout()); } - return data; + return req; }, - catch(resp, restart) { - /* - { - "name": "Unauthorized", - "message": "You are requesting with an invalid credential.", - "code": 0, - "status": 401, - "type": "yii\\web\\UnauthorizedHttpException" - } - { - "name": "Unauthorized", - "message": "Token expired", - "code": 0, - "status": 401, - "type": "yii\\web\\UnauthorizedHttpException" - } - */ - if (resp && resp.status === 401) { + catch(resp, req, restart) { + if (resp && resp.status === 401 && req.options.autoRefreshToken !== false) { const {refreshToken} = getState().user; if (resp.message === 'Token expired' && refreshToken) { // request token and retry diff --git a/src/services/api/accounts.js b/src/services/api/accounts.js index 34dac16..014ec76 100644 --- a/src/services/api/accounts.js +++ b/src/services/api/accounts.js @@ -1,8 +1,18 @@ import request from 'services/request'; export default { - current() { - return request.get('/api/accounts/current'); + /** + * @param {object} options + * @param {object} [options.token] - an optional token to overwrite headers in middleware + * @param {bool} [options.autoRefreshToken=true] - disable token auto refresh during request + * + * @return {Promise} + */ + current(options = {}) { + return request.get('/api/accounts/current', {}, { + token: options.token, + autoRefreshToken: options.autoRefreshToken + }); }, changePassword({ diff --git a/src/services/api/authentication.js b/src/services/api/authentication.js index 52e5fa7..7c005db 100644 --- a/src/services/api/authentication.js +++ b/src/services/api/authentication.js @@ -1,4 +1,5 @@ import request from 'services/request'; +import accounts from 'services/api/accounts'; export default { login({ @@ -36,6 +37,23 @@ export default { ); }, + /** + * Resolves if token is valid + * + * @param {object} options + * @param {string} options.token + * @param {string} options.refreshToken + * + * @return {Promise} - resolves with options.token or with a new token + * if it was refreshed + */ + validateToken({token, refreshToken}) { + // TODO: use refresh token to get fresh token. Dont forget, that it may be broken by refreshTokenMiddleware + // TODO: cover with tests + return accounts.current({token, autoRefreshToken: false}) + .then(() => ({token, refreshToken})); + }, + /** * Request new access token using a refreshToken * diff --git a/src/services/request/request.js b/src/services/request/request.js index a94a433..7ea6640 100644 --- a/src/services/request/request.js +++ b/src/services/request/request.js @@ -5,33 +5,36 @@ const middlewareLayer = new PromiseMiddlewareLayer(); export default { /** * @param {string} url - * @param {object} data + * @param {object} data - request data + * @param {object} options - additional options for fetch or middlewares * * @return {Promise} */ - post(url, data) { + post(url, data, options = {}) { return doFetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, - body: buildQuery(data) + body: buildQuery(data), + ...options }); }, /** * @param {string} url - * @param {object} data + * @param {object} data - request data + * @param {object} options - additional options for fetch or middlewares * * @return {Promise} */ - get(url, data) { - if (typeof data === 'object') { + get(url, data, options = {}) { + if (typeof data === 'object' && Object.keys(data).length) { const separator = url.indexOf('?') === -1 ? '?' : '&'; url += separator + buildQuery(data); } - return doFetch(url); + return doFetch(url, options); }, /** @@ -82,8 +85,8 @@ function doFetch(url, options = {}) { .then(checkStatus) .then(toJSON, rejectWithJSON) .then(handleResponseSuccess) - .then((resp) => middlewareLayer.run('then', resp)) - .catch((resp) => middlewareLayer.run('catch', resp, () => doFetch(url, options))) + .then((resp) => middlewareLayer.run('then', resp, {url, options})) + .catch((resp) => middlewareLayer.run('catch', resp, {url, options}, () => doFetch(url, options))) ; } diff --git a/tests/components/accounts/actions.test.js b/tests/components/accounts/actions.test.js new file mode 100644 index 0000000..2cd69d7 --- /dev/null +++ b/tests/components/accounts/actions.test.js @@ -0,0 +1,134 @@ +import expect from 'unexpected'; + +import accounts from 'services/api/accounts'; +import { authenticate, revoke, add, activate, remove, ADD, REMOVE, ACTIVATE } from 'components/accounts/actions'; + +import { updateUser, logout } from 'components/user/actions'; + +const account = { + id: 1, + username: 'username', + email: 'email@test.com', + token: 'foo', + refreshToken: 'foo' +}; + +const user = { + id: 1, + username: 'username', + email: 'email@test.com', +}; + +describe('Accounts actions', () => { + let dispatch; + let getState; + + beforeEach(() => { + dispatch = sinon.spy(function dispatch(arg) { + return typeof arg === 'function' ? arg(dispatch, getState) : arg; + }).named('dispatch'); + getState = sinon.stub().named('getState'); + + getState.returns({ + accounts: [], + user: {} + }); + + sinon.stub(accounts, 'current').named('accounts.current'); + accounts.current.returns(Promise.resolve(user)); + }); + + afterEach(() => { + accounts.current.restore(); + }); + + describe('#authenticate()', () => { + it('should request user state using token', () => { + authenticate(account)(dispatch); + + expect(accounts.current, 'to have a call satisfying', [ + {token: account.token} + ]); + }); + + it(`dispatches ${ADD} action`, () => + authenticate(account)(dispatch).then(() => + expect(dispatch, 'to have a call satisfying', [ + add(account) + ]) + ) + ); + + it(`dispatches ${ACTIVATE} action`, () => + authenticate(account)(dispatch).then(() => + expect(dispatch, 'to have a call satisfying', [ + activate(account) + ]) + ) + ); + + it('should update user state', () => + authenticate(account)(dispatch).then(() => + expect(dispatch, 'to have a call satisfying', [ + updateUser(user) + ]) + ) + ); + + it('resolves with account', () => + authenticate(account)(dispatch).then((resp) => + expect(resp, 'to equal', account) + ) + ); + + it('rejects when bad auth data', () => { + accounts.current.returns(Promise.reject({})); + + const promise = authenticate(account)(dispatch); + + expect(promise, 'to be rejected'); + + return promise.catch(() => { + expect(dispatch, 'was not called'); + return Promise.resolve(); + }); + }); + }); + + describe('#revoke()', () => { + it(`should dispatch ${REMOVE} action`, () => { + revoke(account)(dispatch, getState); + + expect(dispatch, 'to have a call satisfying', [ + remove(account) + ]); + }); + + it('should switch next account if available', () => { + const account2 = {...account, id: 2}; + + getState.returns({ + accounts: [account2] + }); + + return revoke(account)(dispatch, getState).then(() => + expect(dispatch, 'to have calls satisfying', [ + [remove(account)], + [expect.it('to be a function')] + // [authenticate(account2)] // TODO: this is not a plain action. How should we simplify its testing? + ]) + ); + }); + + it('should logout if no other accounts available', () => { + revoke(account)(dispatch, getState) + .then(() => + 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? + ]) + ); + }); + }); +}); diff --git a/tests/components/accounts/reducer.test.js b/tests/components/accounts/reducer.test.js new file mode 100644 index 0000000..d9d34ec --- /dev/null +++ b/tests/components/accounts/reducer.test.js @@ -0,0 +1,87 @@ +import expect from 'unexpected'; + +import accounts from 'components/accounts/reducer'; +import { ADD, REMOVE, ACTIVATE } from 'components/accounts/actions'; + +const account = { + id: 1, + username: 'username', + email: 'email@test.com', + token: 'foo', + refreshToken: 'foo' +}; + +describe('Accounts reducer', () => { + let initial; + + beforeEach(() => { + initial = accounts(null, {}); + }); + + it('should be empty', () => expect(accounts(null, {}), 'to equal', { + active: null, + available: [] + })); + + describe(ACTIVATE, () => { + it('sets active account', () => { + expect(accounts(initial, { + type: ACTIVATE, + payload: account + }), 'to satisfy', { + active: account + }); + }); + }); + + describe(ADD, () => { + it('adds an account', () => + expect(accounts(initial, { + type: ADD, + payload: account + }), 'to satisfy', { + available: [account] + }) + ); + + it('should not add the same account twice', () => + expect(accounts({...initial, available: [account]}, { + type: ADD, + payload: account + }), 'to satisfy', { + available: [account] + }) + ); + + it('throws, when account is invalid', () => { + expect(() => accounts(initial, { + type: ADD + }), 'to throw', 'Invalid or empty payload passed for accounts.add'); + + expect(() => accounts(initial, { + type: ADD, + payload: {} + }), 'to throw', 'Invalid or empty payload passed for accounts.add'); + }); + }); + + describe(REMOVE, () => { + it('should remove an account', () => + expect(accounts({...initial, available: [account]}, { + type: REMOVE, + payload: account + }), 'to equal', initial) + ); + + it('throws, when account is invalid', () => { + expect(() => accounts(initial, { + type: REMOVE + }), 'to throw', 'Invalid or empty payload passed for accounts.remove'); + + expect(() => accounts(initial, { + type: REMOVE, + payload: {} + }), 'to throw', 'Invalid or empty payload passed for accounts.remove'); + }); + }); +}); diff --git a/tests/components/user/middlewares/bearerHeaderMiddleware.test.js b/tests/components/user/middlewares/bearerHeaderMiddleware.test.js index 0d2ea5e..49ee57b 100644 --- a/tests/components/user/middlewares/bearerHeaderMiddleware.test.js +++ b/tests/components/user/middlewares/bearerHeaderMiddleware.test.js @@ -3,7 +3,7 @@ import expect from 'unexpected'; import bearerHeaderMiddleware from 'components/user/middlewares/bearerHeaderMiddleware'; describe('bearerHeaderMiddleware', () => { - it('should set Authorization header', () => { + describe('when token available', () => { const token = 'foo'; const middleware = bearerHeaderMiddleware({ getState: () => ({ @@ -11,16 +11,34 @@ describe('bearerHeaderMiddleware', () => { }) }); - const data = { - options: { - headers: {} - } - }; + it('should set Authorization header', () => { + const data = { + options: { + headers: {} + } + }; - middleware.before(data); + middleware.before(data); - expect(data.options.headers, 'to satisfy', { - Authorization: `Bearer ${token}` + expect(data.options.headers, 'to satisfy', { + Authorization: `Bearer ${token}` + }); + }); + + it('overrides user.token with options.token if available', () => { + const tokenOverride = 'tokenOverride'; + const data = { + options: { + headers: {}, + token: tokenOverride + } + }; + + middleware.before(data); + + expect(data.options.headers, 'to satisfy', { + Authorization: `Bearer ${tokenOverride}` + }); }); }); diff --git a/tests/components/user/middlewares/refreshTokenMiddleware.test.js b/tests/components/user/middlewares/refreshTokenMiddleware.test.js index bd8559c..8282b02 100644 --- a/tests/components/user/middlewares/refreshTokenMiddleware.test.js +++ b/tests/components/user/middlewares/refreshTokenMiddleware.test.js @@ -28,29 +28,64 @@ describe('refreshTokenMiddleware', () => { }); describe('#before', () => { - it('should request new token', () => { - getState.returns({ - user: { - token: expiredToken, - refreshToken - } + describe('when token expired', () => { + beforeEach(() => { + getState.returns({ + user: { + token: expiredToken, + refreshToken + } + }); }); - const data = { - url: 'foo', - options: { - headers: {} - } - }; + it('should request new token', () => { + const data = { + url: 'foo', + options: { + headers: {} + } + }; - authentication.requestToken.returns(Promise.resolve({token: validToken})); + authentication.requestToken.returns(Promise.resolve({token: validToken})); + + return middleware.before(data).then((resp) => { + expect(resp, 'to satisfy', data); + + expect(authentication.requestToken, 'to have a call satisfying', [ + refreshToken + ]); + }); + }); + + it('should not apply to refresh-token request', () => { + const data = {url: '/refresh-token'}; + const resp = middleware.before(data); - return middleware.before(data).then((resp) => { expect(resp, 'to satisfy', data); - expect(authentication.requestToken, 'to have a call satisfying', [ - refreshToken - ]); + expect(authentication.requestToken, 'was not called'); + }); + + it('should not apply if options.autoRefreshToken === false', () => { + const data = { + url: 'foo', + options: {autoRefreshToken: false} + }; + middleware.before(data); + + expect(authentication.requestToken, 'was not called'); + }); + + xit('should update user with new token'); // TODO: need a way to test, that action was called + xit('should logout if invalid token'); // TODO: need a way to test, that action was called + + xit('should logout if token request failed', () => { + authentication.requestToken.returns(Promise.reject()); + + return middleware.before({url: 'foo'}).then((resp) => { + // TODO: need a way to test, that action was called + expect(dispatch, 'to have a call satisfying', logout); + }); }); }); @@ -66,74 +101,78 @@ describe('refreshTokenMiddleware', () => { expect(authentication.requestToken, 'was not called'); }); - - it('should not apply to refresh-token request', () => { - getState.returns({ - user: {} - }); - - const data = {url: '/refresh-token'}; - const resp = middleware.before(data); - - expect(resp, 'to satisfy', data); - - expect(authentication.requestToken, 'was not called'); - }); - - xit('should update user with new token'); // TODO: need a way to test, that action was called - xit('should logout if invalid token'); // TODO: need a way to test, that action was called - - xit('should logout if token request failed', () => { - getState.returns({ - user: { - token: expiredToken, - refreshToken - } - }); - - authentication.requestToken.returns(Promise.reject()); - - return middleware.before({url: 'foo'}).then((resp) => { - // TODO: need a way to test, that action was called - expect(dispatch, 'to have a call satisfying', logout); - }); - }); }); describe('#catch', () => { - it('should request new token', () => { + const expiredResponse = { + name: 'Unauthorized', + message: 'Token expired', + code: 0, + status: 401, + type: 'yii\\web\\UnauthorizedHttpException' + }; + + const badTokenReponse = { + name: 'Unauthorized', + message: 'Token expired', + code: 0, + status: 401, + type: 'yii\\web\\UnauthorizedHttpException' + }; + + let restart; + + beforeEach(() => { getState.returns({ user: { refreshToken } }); - const restart = sinon.stub().named('restart'); + restart = sinon.stub().named('restart'); authentication.requestToken.returns(Promise.resolve({token: validToken})); + }); - return middleware.catch({ - status: 401, - message: 'Token expired' - }, restart).then(() => { + it('should request new token if expired', () => + middleware.catch(expiredResponse, {options: {}}, restart).then(() => { expect(authentication.requestToken, 'to have a call satisfying', [ refreshToken ]); expect(restart, 'was called'); + }) + ); + + xit('should logout user if token cannot be refreshed', () => { + // TODO: need a way to test, that action was called + return middleware.catch(badTokenReponse, {options: {}}, restart).then(() => { + // TODO }); }); - xit('should logout user if token cannot be refreshed'); // TODO: need a way to test, that action was called + it('should pass the request through if options.autoRefreshToken === false', () => { + const promise = middleware.catch(expiredResponse, { + options: { + autoRefreshToken: false + } + }, restart); + + return expect(promise, 'to be rejected with', expiredResponse).then(() => { + expect(restart, 'was not called'); + expect(authentication.requestToken, 'was not called'); + }); + }); it('should pass the rest of failed requests through', () => { const resp = {}; - const promise = middleware.catch(resp); + const promise = middleware.catch(resp, { + options: {} + }, restart); - expect(promise, 'to be rejected'); - - return promise.catch((actual) => { - expect(actual, 'to be', resp); + return expect(promise, 'to be rejected with', resp).then(() => { + expect(restart, 'was not called'); + expect(authentication.requestToken, 'was not called'); }); }); }); From 8601da786cbef65c440c29b2c3eb322f57219c27 Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Mon, 31 Oct 2016 08:51:38 +0200 Subject: [PATCH 02/34] #48: add accounts reducer and set up localStorage persistance --- package.json | 1 + src/reducers.js | 2 ++ src/storeFactory.js | 8 ++++++-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0f4c716..c7bb99d 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/reducers.js b/src/reducers.js index 90bab89..7161456 100644 --- a/src/reducers.js +++ b/src/reducers.js @@ -4,6 +4,7 @@ import { routeReducer } from 'react-router-redux'; import auth from 'components/auth/reducer'; import user from 'components/user/reducer'; +import accounts from 'components/accounts/reducer'; import i18n from 'components/i18n/reducer'; import popup from 'components/ui/popup/reducer'; import bsod from 'components/ui/bsod/reducer'; @@ -12,6 +13,7 @@ export default combineReducers({ bsod, auth, user, + accounts, i18n, popup, routing: routeReducer diff --git a/src/storeFactory.js b/src/storeFactory.js index 0febcd6..90b1dd6 100644 --- a/src/storeFactory.js +++ b/src/storeFactory.js @@ -4,6 +4,7 @@ import { createStore, applyMiddleware, compose } from 'redux'; // а также дает возможность проверить какие-либо условия перед запуском экшена // или даже вообще его не запускать в зависимости от условий import thunk from 'redux-thunk'; +import persistState from 'redux-localstorage'; import { syncHistory } from 'react-router-redux'; import { browserHistory } from 'react-router'; @@ -15,14 +16,17 @@ export default function storeFactory() { reduxRouterMiddleware, thunk ); + const persistStateEnhancer = persistState([ + 'accounts' + ], {key: 'redux-storage'}); /* global process: false */ let enhancer; if (process.env.NODE_ENV === 'production') { - enhancer = compose(middlewares); + enhancer = compose(middlewares, persistStateEnhancer); } else { const DevTools = require('containers/DevTools').default; - enhancer = compose(middlewares, DevTools.instrument()); + enhancer = compose(middlewares, persistStateEnhancer, DevTools.instrument()); } const store = createStore(reducers, {}, enhancer); From 000ce71d3e7c6dd0a8d1326bc29d48447368711e Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 5 Nov 2016 12:11:41 +0200 Subject: [PATCH 03/34] #48: integrate accounts with app --- src/components/accounts/actions.js | 20 ++- src/components/accounts/reducer.js | 23 ++- src/components/auth/actions.js | 8 +- src/components/i18n/actions.js | 24 ++- src/components/user/User.js | 4 - src/components/user/actions.js | 61 +++---- src/components/user/factory.js | 9 +- .../middlewares/bearerHeaderMiddleware.js | 4 +- .../middlewares/refreshTokenMiddleware.js | 32 +++- src/services/api/authentication.js | 29 ++- src/storeFactory.js | 3 +- tests/components/accounts/actions.test.js | 96 ++++++---- tests/components/accounts/reducer.test.js | 69 +++---- tests/components/user/actions.test.js | 6 +- .../bearerHeaderMiddleware.test.js | 50 +++++- .../refreshTokenMiddleware.test.js | 169 +++++++++++++++--- tests/services/api/authentication.test.js | 91 ++++++++++ 17 files changed, 517 insertions(+), 181 deletions(-) create mode 100644 tests/services/api/authentication.test.js diff --git a/src/components/accounts/actions.js b/src/components/accounts/actions.js index 7365783..db8a22c 100644 --- a/src/components/accounts/actions.js +++ b/src/components/accounts/actions.js @@ -1,6 +1,7 @@ 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 @@ -35,9 +36,13 @@ export function authenticate({token, refreshToken}) { .then(({user, account}) => { dispatch(add(account)); dispatch(activate(account)); - dispatch(updateUser(user)); + dispatch(updateUser({ + isGuest: false, + ...user + })); - return account; + return dispatch(setLocale(user.lang)) + .then(() => account); }); }; } @@ -95,3 +100,14 @@ export function activate(account) { payload: account }; } + +export const UPDATE_TOKEN = 'accounts:updateToken'; +/** + * @param {string} token + */ +export function updateToken(token) { + return { + type: UPDATE_TOKEN, + payload: token + }; +} diff --git a/src/components/accounts/reducer.js b/src/components/accounts/reducer.js index eea892a..a08c3aa 100644 --- a/src/components/accounts/reducer.js +++ b/src/components/accounts/reducer.js @@ -1,4 +1,4 @@ -import { ADD, REMOVE, ACTIVATE } from './actions'; +import { ADD, REMOVE, ACTIVATE, UPDATE_TOKEN } from './actions'; /** * @typedef {AccountsState} @@ -14,7 +14,10 @@ import { ADD, REMOVE, ACTIVATE } from './actions'; * @return {AccountsState} */ export default function accounts( - state, + state = { + active: null, + available: [] + }, {type, payload = {}} ) { switch (type) { @@ -49,10 +52,20 @@ export default function accounts( available: state.available.filter((account) => account.id !== payload.id) }; - default: + case UPDATE_TOKEN: + if (typeof payload !== 'string') { + throw new Error('payload must be a jwt token'); + } + return { - active: null, - available: [] + ...state, + active: { + ...state.active, + token: payload + } }; + + default: + return state; } } diff --git a/src/components/auth/actions.js b/src/components/auth/actions.js index cbe2fce..c61c6ac 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 as logoutUser, 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'; @@ -305,7 +306,10 @@ 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 + })); } function validationErrorsHandler(dispatch, repeatUrl) { 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/user/User.js b/src/components/user/User.js index 568d8b2..d90ae98 100644 --- a/src/components/user/User.js +++ b/src/components/user/User.js @@ -33,10 +33,6 @@ export default class User { // frontend app specific attributes isGuest: true, goal: null, // the goal with wich user entered site - - // TODO: the following does not belongs here. Move it later - token: '', - refreshToken: '', }; const user = Object.keys(defaults).reduce((user, key) => { diff --git a/src/components/user/actions.js b/src/components/user/actions.js index 9d21232..713233f 100644 --- a/src/components/user/actions.js +++ b/src/components/user/actions.js @@ -1,14 +1,15 @@ import { routeActions } from 'react-router-redux'; -import captcha from 'services/captcha'; import accounts from 'services/api/accounts'; import authentication from 'services/api/authentication'; import { setLocale } from 'components/i18n/actions'; export const UPDATE = 'USER_UPDATE'; /** - * @param {string|object} payload jwt token or user object - * @return {object} action definition + * Merge data into user's state + * + * @param {object} payload + * @return {object} - action definition */ export function updateUser(payload) { return { @@ -23,23 +24,26 @@ export function changeLang(lang) { .then((lang) => { const {user: {isGuest, lang: oldLang}} = getState(); - if (!isGuest && oldLang !== lang) { - accounts.changeLang(lang); + if (oldLang !== lang) { + !isGuest && accounts.changeLang(lang); + + dispatch({ + type: CHANGE_LANG, + payload: { + lang + } + }); } - - // TODO: probably should be moved from here, because it is side effect - captcha.setLang(lang); - - dispatch({ - type: CHANGE_LANG, - payload: { - lang - } - }); }); } export const SET = 'USER_SET'; +/** + * Replace current user's state with a new one + * + * @param {User} payload + * @return {object} - action definition + */ export function setUser(payload) { return { type: SET, @@ -72,7 +76,10 @@ export function fetchUserData() { return (dispatch) => accounts.current() .then((resp) => { - dispatch(updateUser(resp)); + dispatch(updateUser({ + isGuest: false, + ...resp + })); return dispatch(changeLang(resp.lang)); }); @@ -80,31 +87,11 @@ export function fetchUserData() { export function acceptRules() { return (dispatch) => - accounts.acceptRules() - .then((resp) => { + accounts.acceptRules().then((resp) => { dispatch(updateUser({ shouldAcceptRules: false })); - return resp; - }) - ; -} - -export function authenticate(token, refreshToken) { // TODO: this action, probably, belongs to components/auth - return (dispatch, getState) => { - refreshToken = refreshToken || getState().user.refreshToken; - dispatch(updateUser({ - token, - refreshToken - })); - - return dispatch(fetchUserData()).then((resp) => { - dispatch(updateUser({ - isGuest: false - })); return resp; }); - }; } - diff --git a/src/components/user/factory.js b/src/components/user/factory.js index 2c471fa..d7cd7e6 100644 --- a/src/components/user/factory.js +++ b/src/components/user/factory.js @@ -1,4 +1,5 @@ -import { authenticate, changeLang } from 'components/user/actions'; +import { changeLang } from 'components/user/actions'; +import { authenticate } from 'components/accounts/actions'; import request from 'services/request'; import bearerHeaderMiddleware from './middlewares/bearerHeaderMiddleware'; @@ -22,11 +23,11 @@ export function factory(store) { request.addMiddleware(bearerHeaderMiddleware(store)); promise = new Promise((resolve, reject) => { - const {user} = store.getState(); + const {user, accounts} = store.getState(); - if (user.token) { + if (accounts.active || user.token) { // authorizing user if it is possible - return store.dispatch(authenticate(user.token)).then(resolve, reject); + return store.dispatch(authenticate(accounts.active || user)).then(resolve, reject); } // auto-detect guests language diff --git a/src/components/user/middlewares/bearerHeaderMiddleware.js b/src/components/user/middlewares/bearerHeaderMiddleware.js index cb1c9db..077d5d1 100644 --- a/src/components/user/middlewares/bearerHeaderMiddleware.js +++ b/src/components/user/middlewares/bearerHeaderMiddleware.js @@ -9,7 +9,9 @@ export default function bearerHeaderMiddleware({getState}) { return { before(req) { - let {token} = getState().user; + const {user, accounts} = getState(); + + let {token} = accounts.active ? accounts.active : user; if (req.options.token) { token = req.options.token; diff --git a/src/components/user/middlewares/refreshTokenMiddleware.js b/src/components/user/middlewares/refreshTokenMiddleware.js index 9f6cc9c..5bbcd93 100644 --- a/src/components/user/middlewares/refreshTokenMiddleware.js +++ b/src/components/user/middlewares/refreshTokenMiddleware.js @@ -1,5 +1,6 @@ import authentication from 'services/api/authentication'; -import {updateUser, logout} from '../actions'; +import { updateToken } from 'components/accounts/actions'; +import { logout } from '../actions'; /** * Ensures, that all user's requests have fresh access token @@ -13,9 +14,21 @@ import {updateUser, logout} from '../actions'; export default function refreshTokenMiddleware({dispatch, getState}) { return { before(req) { - const {refreshToken, token} = getState().user; + const {user, accounts} = getState(); + + let refreshToken; + let token; + const isRefreshTokenRequest = req.url.includes('refresh-token'); + if (accounts.active) { + token = accounts.active.token; + refreshToken = accounts.active.refreshToken; + } else { // #legacy token + token = user.token; + refreshToken = user.refreshToken; + } + if (!token || isRefreshTokenRequest || req.options.autoRefreshToken === false) { return req; } @@ -28,21 +41,24 @@ export default function refreshTokenMiddleware({dispatch, getState}) { return requestAccessToken(refreshToken, dispatch).then(() => req); } } catch (err) { - dispatch(logout()); + // console.error('Bad token', err); // TODO: it would be cool to log such things to backend + return dispatch(logout()).then(() => req); } - return req; + return Promise.resolve(req); }, catch(resp, req, restart) { if (resp && resp.status === 401 && req.options.autoRefreshToken !== false) { - const {refreshToken} = getState().user; + const {user, accounts} = getState(); + const {refreshToken} = accounts.active ? accounts.active : user; + if (resp.message === 'Token expired' && refreshToken) { // request token and retry return requestAccessToken(refreshToken, dispatch).then(restart); } - dispatch(logout()); + return dispatch(logout()).then(() => Promise.reject(resp)); } return Promise.reject(resp); @@ -59,9 +75,7 @@ function requestAccessToken(refreshToken, dispatch) { } return promise - .then(({token}) => dispatch(updateUser({ - token - }))) + .then(({token}) => dispatch(updateToken(token))) .catch(() => dispatch(logout())); } diff --git a/src/services/api/authentication.js b/src/services/api/authentication.js index 7c005db..0d2c2cb 100644 --- a/src/services/api/authentication.js +++ b/src/services/api/authentication.js @@ -1,7 +1,7 @@ import request from 'services/request'; import accounts from 'services/api/accounts'; -export default { +const authentication = { login({ login = '', password = '', @@ -48,10 +48,27 @@ export default { * if it was refreshed */ validateToken({token, refreshToken}) { - // TODO: use refresh token to get fresh token. Dont forget, that it may be broken by refreshTokenMiddleware - // TODO: cover with tests - return accounts.current({token, autoRefreshToken: false}) - .then(() => ({token, refreshToken})); + return new Promise((resolve) => { + if (typeof token !== 'string') { + throw new Error('token must be a string'); + } + + if (typeof refreshToken !== 'string') { + throw new Error('refreshToken must be a string'); + } + + resolve(); + }) + .then(() => accounts.current({token, autoRefreshToken: false})) + .then(() => ({token, refreshToken})) + .catch((resp) => { + if (resp.message === 'Token expired') { + return authentication.requestToken(refreshToken) + .then(({token}) => ({token, refreshToken})); + } + + return Promise.reject(resp); + }); }, /** @@ -70,3 +87,5 @@ export default { })); } }; + +export default authentication; diff --git a/src/storeFactory.js b/src/storeFactory.js index 90b1dd6..b393efd 100644 --- a/src/storeFactory.js +++ b/src/storeFactory.js @@ -17,7 +17,8 @@ export default function storeFactory() { thunk ); const persistStateEnhancer = persistState([ - 'accounts' + 'accounts', + 'user' ], {key: 'redux-storage'}); /* global process: false */ diff --git a/tests/components/accounts/actions.test.js b/tests/components/accounts/actions.test.js index 2cd69d7..9c26c5f 100644 --- a/tests/components/accounts/actions.test.js +++ b/tests/components/accounts/actions.test.js @@ -2,21 +2,23 @@ import expect from 'unexpected'; import accounts from 'services/api/accounts'; import { authenticate, revoke, add, activate, remove, ADD, REMOVE, ACTIVATE } from 'components/accounts/actions'; +import { SET_LOCALE } from 'components/i18n/actions'; -import { updateUser, logout } from 'components/user/actions'; +import { updateUser } from 'components/user/actions'; const account = { id: 1, username: 'username', email: 'email@test.com', token: 'foo', - refreshToken: 'foo' + refreshToken: 'bar' }; const user = { id: 1, username: 'username', email: 'email@test.com', + lang: 'be' }; describe('Accounts actions', () => { @@ -24,10 +26,10 @@ describe('Accounts actions', () => { let getState; beforeEach(() => { - dispatch = sinon.spy(function dispatch(arg) { - return typeof arg === 'function' ? arg(dispatch, getState) : arg; - }).named('dispatch'); - getState = sinon.stub().named('getState'); + dispatch = sinon.spy((arg) => + typeof arg === 'function' ? arg(dispatch, getState) : arg + ).named('store.dispatch'); + getState = sinon.stub().named('store.getState'); getState.returns({ accounts: [], @@ -43,13 +45,13 @@ describe('Accounts actions', () => { }); describe('#authenticate()', () => { - it('should request user state using token', () => { - authenticate(account)(dispatch); - - expect(accounts.current, 'to have a call satisfying', [ - {token: account.token} - ]); - }); + it('should request user state using token', () => + authenticate(account)(dispatch).then(() => + expect(accounts.current, 'to have a call satisfying', [ + {token: account.token} + ]) + ) + ); it(`dispatches ${ADD} action`, () => authenticate(account)(dispatch).then(() => @@ -67,10 +69,18 @@ describe('Accounts actions', () => { ) ); + it(`dispatches ${SET_LOCALE} action`, () => + authenticate(account)(dispatch).then(() => + expect(dispatch, 'to have a call satisfying', [ + {type: SET_LOCALE, payload: {locale: 'be'}} + ]) + ) + ); + it('should update user state', () => authenticate(account)(dispatch).then(() => expect(dispatch, 'to have a call satisfying', [ - updateUser(user) + updateUser({...user, isGuest: false}) ]) ) ); @@ -84,14 +94,9 @@ describe('Accounts actions', () => { it('rejects when bad auth data', () => { accounts.current.returns(Promise.reject({})); - const promise = authenticate(account)(dispatch); - - expect(promise, 'to be rejected'); - - return promise.catch(() => { - expect(dispatch, 'was not called'); - return Promise.resolve(); - }); + return expect(authenticate(account)(dispatch), 'to be rejected').then(() => + expect(dispatch, 'was not called') + ); }); }); @@ -108,27 +113,42 @@ describe('Accounts actions', () => { const account2 = {...account, id: 2}; getState.returns({ - accounts: [account2] + accounts: [account] }); - return revoke(account)(dispatch, getState).then(() => - expect(dispatch, 'to have calls satisfying', [ - [remove(account)], - [expect.it('to be a function')] - // [authenticate(account2)] // TODO: this is not a plain action. How should we simplify its testing? - ]) - ); + return revoke(account2)(dispatch, getState).then(() => { + expect(dispatch, 'to have a call satisfying', [ + remove(account2) + ]); + expect(dispatch, 'to have a call satisfying', [ + activate(account) + ]); + expect(dispatch, 'to have a call satisfying', [ + updateUser({...user, isGuest: false}) + ]); + // expect(dispatch, 'to have calls satisfying', [ + // [remove(account2)], + // [expect.it('to be a function')] + // // [authenticate(account2)] // TODO: this is not a plain action. How should we simplify its testing? + // ]) + }); }); it('should logout if no other accounts available', () => { - revoke(account)(dispatch, getState) - .then(() => - 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? - ]) - ); + revoke(account)(dispatch, getState).then(() => { + expect(dispatch, 'to have a call satisfying', [ + remove(account) + ]); + expect(dispatch, 'to have a call satisfying', [ + {payload: {isGuest: true}} + // updateUser({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? + // ]) + }); }); }); }); diff --git a/tests/components/accounts/reducer.test.js b/tests/components/accounts/reducer.test.js index d9d34ec..58ef367 100644 --- a/tests/components/accounts/reducer.test.js +++ b/tests/components/accounts/reducer.test.js @@ -1,7 +1,10 @@ import expect from 'unexpected'; import accounts from 'components/accounts/reducer'; -import { ADD, REMOVE, ACTIVATE } from 'components/accounts/actions'; +import { + updateToken, add, remove, activate, + ADD, REMOVE, ACTIVATE, UPDATE_TOKEN +} from 'components/accounts/actions'; const account = { id: 1, @@ -15,20 +18,21 @@ describe('Accounts reducer', () => { let initial; beforeEach(() => { - initial = accounts(null, {}); + initial = accounts(undefined, {}); }); - it('should be empty', () => expect(accounts(null, {}), 'to equal', { + 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'}) + ); + describe(ACTIVATE, () => { it('sets active account', () => { - expect(accounts(initial, { - type: ACTIVATE, - payload: account - }), 'to satisfy', { + expect(accounts(initial, activate(account)), 'to satisfy', { active: account }); }); @@ -36,52 +40,49 @@ describe('Accounts reducer', () => { describe(ADD, () => { it('adds an account', () => - expect(accounts(initial, { - type: ADD, - payload: account - }), 'to satisfy', { + expect(accounts(initial, add(account)), 'to satisfy', { available: [account] }) ); it('should not add the same account twice', () => - expect(accounts({...initial, available: [account]}, { - type: ADD, - payload: account - }), 'to satisfy', { + expect(accounts({...initial, available: [account]}, add(account)), 'to satisfy', { available: [account] }) ); it('throws, when account is invalid', () => { - expect(() => accounts(initial, { - type: ADD - }), 'to throw', 'Invalid or empty payload passed for accounts.add'); - - expect(() => accounts(initial, { - type: ADD, - payload: {} - }), 'to throw', 'Invalid or empty payload passed for accounts.add'); + expect(() => accounts(initial, add()), + 'to throw', 'Invalid or empty payload passed for accounts.add'); }); }); describe(REMOVE, () => { it('should remove an account', () => - expect(accounts({...initial, available: [account]}, { - type: REMOVE, - payload: account - }), 'to equal', initial) + expect(accounts({...initial, available: [account]}, remove(account)), + 'to equal', initial) ); it('throws, when account is invalid', () => { - expect(() => accounts(initial, { - type: REMOVE - }), 'to throw', 'Invalid or empty payload passed for accounts.remove'); + expect(() => accounts(initial, remove()), + 'to throw', 'Invalid or empty payload passed for accounts.remove'); + }); + }); - expect(() => accounts(initial, { - type: REMOVE, - payload: {} - }), 'to throw', 'Invalid or empty payload passed for accounts.remove'); + describe(UPDATE_TOKEN, () => { + it('should update token', () => { + const newToken = 'newToken'; + + expect(accounts( + {active: account, available: [account]}, + updateToken(newToken) + ), 'to satisfy', { + active: { + ...account, + token: newToken + }, + available: [account] + }); }); }); }); diff --git a/tests/components/user/actions.test.js b/tests/components/user/actions.test.js index 003cebd..0b05cb0 100644 --- a/tests/components/user/actions.test.js +++ b/tests/components/user/actions.test.js @@ -11,8 +11,10 @@ import { describe('components/user/actions', () => { - const dispatch = sinon.stub().named('dispatch'); - const getState = sinon.stub().named('getState'); + const getState = sinon.stub().named('store.getState'); + const dispatch = sinon.spy((arg) => + typeof arg === 'function' ? arg(dispatch, getState) : arg + ).named('store.dispatch'); const callThunk = function(fn, ...args) { const thunk = fn(...args); diff --git a/tests/components/user/middlewares/bearerHeaderMiddleware.test.js b/tests/components/user/middlewares/bearerHeaderMiddleware.test.js index 49ee57b..f9dc8ef 100644 --- a/tests/components/user/middlewares/bearerHeaderMiddleware.test.js +++ b/tests/components/user/middlewares/bearerHeaderMiddleware.test.js @@ -3,11 +3,21 @@ import expect from 'unexpected'; import bearerHeaderMiddleware from 'components/user/middlewares/bearerHeaderMiddleware'; describe('bearerHeaderMiddleware', () => { + const emptyState = { + user: {}, + accounts: { + active: null + } + }; + describe('when token available', () => { const token = 'foo'; const middleware = bearerHeaderMiddleware({ getState: () => ({ - user: {token} + ...emptyState, + accounts: { + active: {token} + } }) }); @@ -20,9 +30,7 @@ describe('bearerHeaderMiddleware', () => { middleware.before(data); - expect(data.options.headers, 'to satisfy', { - Authorization: `Bearer ${token}` - }); + expectBearerHeader(data, token); }); it('overrides user.token with options.token if available', () => { @@ -36,16 +44,36 @@ describe('bearerHeaderMiddleware', () => { middleware.before(data); - expect(data.options.headers, 'to satisfy', { - Authorization: `Bearer ${tokenOverride}` - }); + expectBearerHeader(data, tokenOverride); + }); + }); + + describe('when legacy token available', () => { + const token = 'foo'; + const middleware = bearerHeaderMiddleware({ + getState: () => ({ + ...emptyState, + user: {token} + }) + }); + + it('should set Authorization header', () => { + const data = { + options: { + headers: {} + } + }; + + middleware.before(data); + + expectBearerHeader(data, token); }); }); it('should not set Authorization header if no token', () => { const middleware = bearerHeaderMiddleware({ getState: () => ({ - user: {} + ...emptyState }) }); @@ -59,4 +87,10 @@ describe('bearerHeaderMiddleware', () => { expect(data.options.headers.Authorization, 'to be undefined'); }); + + function expectBearerHeader(data, token) { + expect(data.options.headers, 'to satisfy', { + Authorization: `Bearer ${token}` + }); + } }); diff --git a/tests/components/user/middlewares/refreshTokenMiddleware.test.js b/tests/components/user/middlewares/refreshTokenMiddleware.test.js index 8282b02..7421738 100644 --- a/tests/components/user/middlewares/refreshTokenMiddleware.test.js +++ b/tests/components/user/middlewares/refreshTokenMiddleware.test.js @@ -3,6 +3,7 @@ import expect from 'unexpected'; import refreshTokenMiddleware from 'components/user/middlewares/refreshTokenMiddleware'; import authentication from 'services/api/authentication'; +import { updateToken } from 'components/accounts/actions'; const refreshToken = 'foo'; const expiredToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYmYiOjE0NzA3NjE0NDMsImV4cCI6MTQ3MDc2MTQ0MywiaWF0IjoxNDcwNzYxNDQzLCJqdGkiOiJpZDEyMzQ1NiJ9.gWdnzfQQvarGpkbldUvB8qdJZSVkvdNtCbhbbl2yJW8'; @@ -18,7 +19,9 @@ describe('refreshTokenMiddleware', () => { sinon.stub(authentication, 'requestToken').named('authentication.requestToken'); getState = sinon.stub().named('store.getState'); - dispatch = sinon.stub().named('store.dispatch'); + dispatch = sinon.spy((arg) => + typeof arg === 'function' ? arg(dispatch, getState) : arg + ).named('store.dispatch'); middleware = refreshTokenMiddleware({getState, dispatch}); }); @@ -27,14 +30,21 @@ describe('refreshTokenMiddleware', () => { authentication.requestToken.restore(); }); + it('must be till 2100 to test with validToken', () => + expect(new Date().getFullYear(), 'to be less than', 2100) + ); + describe('#before', () => { describe('when token expired', () => { beforeEach(() => { getState.returns({ - user: { - token: expiredToken, - refreshToken - } + accounts: { + active: { + token: expiredToken, + refreshToken + } + }, + user: {} }); }); @@ -76,21 +86,94 @@ describe('refreshTokenMiddleware', () => { expect(authentication.requestToken, 'was not called'); }); - xit('should update user with new token'); // TODO: need a way to test, that action was called - xit('should logout if invalid token'); // TODO: need a way to test, that action was called + it('should update user with new token', () => { + const data = { + url: 'foo', + options: { + headers: {} + } + }; - xit('should logout if token request failed', () => { + authentication.requestToken.returns(Promise.resolve({token: validToken})); + + return middleware.before(data).then(() => + expect(dispatch, 'to have a call satisfying', [ + updateToken(validToken) + ]) + ); + }); + + it('should if token can not be parsed', () => { + getState.returns({ + accounts: { + active: { + token: 'realy bad token', + refreshToken + } + }, + user: {} + }); + + const req = {url: 'foo', options: {}}; + + return expect(middleware.before(req), 'to be fulfilled with', req).then(() => { + expect(authentication.requestToken, 'was not called'); + + expect(dispatch, 'to have a call satisfying', [ + {payload: {isGuest: true}} + ]); + }); + }); + + it('should logout if token request failed', () => { authentication.requestToken.returns(Promise.reject()); - return middleware.before({url: 'foo'}).then((resp) => { - // TODO: need a way to test, that action was called - expect(dispatch, 'to have a call satisfying', logout); + return expect(middleware.before({url: 'foo', options: {}}), 'to be fulfilled').then(() => + expect(dispatch, 'to have a call satisfying', [ + {payload: {isGuest: true}} + ]) + ); + }); + }); + + describe('when token expired legacy user', () => { + beforeEach(() => { + getState.returns({ + accounts: { + active: null + }, + user: { + token: expiredToken, + refreshToken + } + }); + }); + + it('should request new token', () => { + const data = { + url: 'foo', + options: { + headers: {} + } + }; + + authentication.requestToken.returns(Promise.resolve({token: validToken})); + + return middleware.before(data).then((resp) => { + expect(resp, 'to satisfy', data); + + expect(authentication.requestToken, 'to have a call satisfying', [ + refreshToken + ]); }); }); }); it('should not be applied if no token', () => { getState.returns({ + accounts: { + active: null + }, user: {} }); @@ -114,7 +197,15 @@ describe('refreshTokenMiddleware', () => { const badTokenReponse = { name: 'Unauthorized', - message: 'Token expired', + message: 'You are requesting with an invalid credential.', + code: 0, + status: 401, + type: 'yii\\web\\UnauthorizedHttpException' + }; + + const incorrectTokenReponse = { + name: 'Unauthorized', + message: 'Incorrect token', code: 0, status: 401, type: 'yii\\web\\UnauthorizedHttpException' @@ -124,9 +215,10 @@ describe('refreshTokenMiddleware', () => { beforeEach(() => { getState.returns({ - user: { - refreshToken - } + accounts: { + active: {refreshToken} + }, + user: {} }); restart = sinon.stub().named('restart'); @@ -143,12 +235,27 @@ describe('refreshTokenMiddleware', () => { }) ); - xit('should logout user if token cannot be refreshed', () => { - // TODO: need a way to test, that action was called - return middleware.catch(badTokenReponse, {options: {}}, restart).then(() => { - // TODO - }); - }); + it('should logout user if invalid credential', () => + expect( + middleware.catch(badTokenReponse, {options: {}}, restart), + 'to be rejected' + ).then(() => + expect(dispatch, 'to have a call satisfying', [ + {payload: {isGuest: true}} + ]) + ) + ); + + it('should logout user if token is incorrect', () => + expect( + middleware.catch(incorrectTokenReponse, {options: {}}, restart), + 'to be rejected' + ).then(() => + expect(dispatch, 'to have a call satisfying', [ + {payload: {isGuest: true}} + ]) + ) + ); it('should pass the request through if options.autoRefreshToken === false', () => { const promise = middleware.catch(expiredResponse, { @@ -175,5 +282,25 @@ describe('refreshTokenMiddleware', () => { expect(authentication.requestToken, 'was not called'); }); }); + + describe('legacy user.refreshToken', () => { + beforeEach(() => { + getState.returns({ + accounts: { + active: null + }, + user: {refreshToken} + }); + }); + + it('should request new token if expired', () => + middleware.catch(expiredResponse, {options: {}}, restart).then(() => { + expect(authentication.requestToken, 'to have a call satisfying', [ + refreshToken + ]); + expect(restart, 'was called'); + }) + ); + }); }); }); diff --git a/tests/services/api/authentication.test.js b/tests/services/api/authentication.test.js new file mode 100644 index 0000000..ef21005 --- /dev/null +++ b/tests/services/api/authentication.test.js @@ -0,0 +1,91 @@ +import expect from 'unexpected'; + +import authentication from 'services/api/authentication'; +import accounts from 'services/api/accounts'; + +describe('authentication api', () => { + describe('#validateToken()', () => { + const validTokens = {token: 'foo', refreshToken: 'bar'}; + + beforeEach(() => { + sinon.stub(accounts, 'current'); + + accounts.current.returns(Promise.resolve()); + }); + + afterEach(() => { + accounts.current.restore(); + }); + + it('should request accounts.current', () => + expect(authentication.validateToken(validTokens), 'to be fulfilled') + .then(() => { + expect(accounts.current, 'to have a call satisfying', [ + {token: 'foo', autoRefreshToken: false} + ]); + }) + ); + + it('should resolve with both tokens', () => + expect(authentication.validateToken(validTokens), 'to be fulfilled with', validTokens) + ); + + it('rejects if token has a bad type', () => + expect(authentication.validateToken({token: {}}), + 'to be rejected with', 'token must be a string' + ) + ); + + it('rejects if refreshToken has a bad type', () => + expect(authentication.validateToken({token: 'foo', refreshToken: {}}), + 'to be rejected with', 'refreshToken must be a string' + ) + ); + + it('rejects if accounts.current request is unexpectedly failed', () => { + const error = 'Something wrong'; + accounts.current.returns(Promise.reject(error)); + + return expect(authentication.validateToken(validTokens), + 'to be rejected with', error + ); + }); + + describe('when token is expired', () => { + const expiredResponse = { + name: 'Unauthorized', + message: 'Token expired', + code: 0, + status: 401, + type: 'yii\\web\\UnauthorizedHttpException' + }; + const newToken = 'baz'; + + beforeEach(() => { + sinon.stub(authentication, 'requestToken'); + + accounts.current.returns(Promise.reject(expiredResponse)); + authentication.requestToken.returns(Promise.resolve({token: newToken})); + }); + + afterEach(() => { + authentication.requestToken.restore(); + }); + + it('resolves with new token', () => + expect(authentication.validateToken(validTokens), + 'to be fulfilled with', {...validTokens, token: newToken} + ) + ); + + it('rejects if token request failed', () => { + const error = 'Something wrong'; + authentication.requestToken.returns(Promise.reject(error)); + + return expect(authentication.validateToken(validTokens), + 'to be rejected with', error + ); + }); + }); + }); +}); From 907ccb39cd63b1790e981844839c19e9e7469b5e Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 5 Nov 2016 12:57:01 +0200 Subject: [PATCH 04/34] #48: create dummy account switcher component --- src/components/accounts/AccountSwitcher.jsx | 77 ++++++++++++++++++++ src/components/accounts/accountSwitcher.scss | 10 +++ src/components/accounts/index.js | 1 + src/components/ui/button-groups.scss | 3 + src/components/userbar/LoggedInPanel.js | 51 ------------- src/components/userbar/LoggedInPanel.jsx | 43 +++++++++++ src/components/userbar/loggedInPanel.scss | 27 ++++++- 7 files changed, 157 insertions(+), 55 deletions(-) create mode 100644 src/components/accounts/AccountSwitcher.jsx create mode 100644 src/components/accounts/accountSwitcher.scss create mode 100644 src/components/accounts/index.js delete mode 100644 src/components/userbar/LoggedInPanel.js create mode 100644 src/components/userbar/LoggedInPanel.jsx diff --git a/src/components/accounts/AccountSwitcher.jsx b/src/components/accounts/AccountSwitcher.jsx new file mode 100644 index 0000000..e6b3b98 --- /dev/null +++ b/src/components/accounts/AccountSwitcher.jsx @@ -0,0 +1,77 @@ +import React, { Component, PropTypes } from 'react'; + +import styles from './accountSwitcher.scss'; + +const accounts = { + active: {id: 7, username: 'SleepWalker', email: 'danilenkos@auroraglobal.com'}, + available: [ + {id: 7, username: 'SleepWalker', email: 'danilenkos@auroraglobal.com'}, + {id: 8, username: 'ErickSkrauch', email: 'erick@foo.bar'}, + {id: 9, username: 'Ely-en', email: 'ely@-enfoo.bar'}, + {id: 10, username: 'Ely-by', email: 'ely-by@foo.bar'}, + ] +}; + +export default class AccountSwitcher extends Component { + render() { + return ( +
+
+
+
+
+ {accounts.active.username} +
+
+ {accounts.active.email} +
+ + Перейти в профиль Ely.by + + + Выйти + +
+
+ {accounts.available.map((account) => ( +
+
+
+
+ {account.username} +
+
+ {account.email} +
+
+
+
+ ))} +
+
+ + + Добавить аккаунт +
+
+
+ ); + } +} + +/* +import { intlShape } from 'react-intl'; + +import messages from './LoggedInPanel.intl.json'; + + + static contextTypes = { + intl: intlShape.isRequired + }; + + */ diff --git a/src/components/accounts/accountSwitcher.scss b/src/components/accounts/accountSwitcher.scss new file mode 100644 index 0000000..b408d2d --- /dev/null +++ b/src/components/accounts/accountSwitcher.scss @@ -0,0 +1,10 @@ +.accountSwitcher { + background: #fff; + color: #444; +} + +.logoutIcon { + composes: exit from 'components/ui/icons.scss'; + + color: #cdcdcd; +} 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/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/userbar/LoggedInPanel.js b/src/components/userbar/LoggedInPanel.js deleted file mode 100644 index 8d9802d..0000000 --- a/src/components/userbar/LoggedInPanel.js +++ /dev/null @@ -1,51 +0,0 @@ -import React, { Component, PropTypes } from 'react'; - -import classNames from 'classnames'; -import { Link } from 'react-router'; -import { intlShape } from 'react-intl'; - -import buttons from 'components/ui/buttons.scss'; -import buttonGroups from 'components/ui/button-groups.scss'; - -import messages from './LoggedInPanel.intl.json'; -import styles from './loggedInPanel.scss'; - -import { userShape } from 'components/user/User'; - -export default class LoggedInPanel extends Component { - static displayName = 'LoggedInPanel'; - static propTypes = { - user: userShape, - onLogout: PropTypes.func.isRequired - }; - - static contextTypes = { - intl: intlShape.isRequired - }; - - render() { - const { user } = this.props; - - return ( -
- - - {user.username} - - -
- ); - } - - onLogout = (event) => { - event.preventDefault(); - - this.props.onLogout(); - }; -} diff --git a/src/components/userbar/LoggedInPanel.jsx b/src/components/userbar/LoggedInPanel.jsx new file mode 100644 index 0000000..4f7fb39 --- /dev/null +++ b/src/components/userbar/LoggedInPanel.jsx @@ -0,0 +1,43 @@ +import React, { Component, PropTypes } from 'react'; + +import classNames from 'classnames'; + +import buttons from 'components/ui/buttons.scss'; +import { AccountSwitcher } from 'components/accounts'; + +import styles from './loggedInPanel.scss'; + +import { userShape } from 'components/user/User'; + +export default class LoggedInPanel extends Component { + static displayName = 'LoggedInPanel'; + static propTypes = { + user: userShape, + onLogout: PropTypes.func.isRequired + }; + + render() { + const { user } = this.props; + + return ( +
+ {/* this button must be a div, because some browsers force overflow hidden on button elements */} +
+ + {user.username} + + +
+ +
+
+
+ ); + } + + onLogout = (event) => { + event.preventDefault(); + + this.props.onLogout(); + }; +} diff --git a/src/components/userbar/loggedInPanel.scss b/src/components/userbar/loggedInPanel.scss index 631a0ad..37f9d77 100644 --- a/src/components/userbar/loggedInPanel.scss +++ b/src/components/userbar/loggedInPanel.scss @@ -1,5 +1,14 @@ .loggedInPanel { - justify-content: flex-end; +} + +.activeAccount { + position: relative; + + &:hover { + .accountSwitcherContainer { + display: block; + } + } } .userIcon { @@ -11,12 +20,22 @@ padding-right: 5px; } +.expandIcon { + composes: arrow from 'components/ui/icons.scss'; + + margin-left: 5px; + font-size: 12px; +} + .userName { } -.logoutIcon { - composes: exit from 'components/ui/icons.scss'; +.accountSwitcherContainer { + position: absolute; + top: 100%; + right: 0; + cursor: auto; - color: #cdcdcd; + display: none; } From 8fbcf275258ab094d84d2d16cadd01dd50fc1328 Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 5 Nov 2016 22:23:56 +0200 Subject: [PATCH 05/34] #48: add dummy ChooseAccountPanel --- .../accounts/AccountSwitcher.intl.json | 5 + src/components/accounts/AccountSwitcher.jsx | 107 ++++++++++++------ src/components/accounts/accountSwitcher.scss | 17 +++ src/components/auth/PanelTransition.jsx | 2 +- src/components/auth/README.md | 6 +- .../chooseAccount/ChooseAccount.intl.json | 5 + .../auth/chooseAccount/ChooseAccount.jsx | 12 ++ .../auth/chooseAccount/ChooseAccountBody.jsx | 36 ++++++ .../auth/chooseAccount/chooseAccount.scss | 6 + src/components/userbar/LoggedInPanel.jsx | 13 +-- src/pages/root/RootPage.jsx | 7 +- src/routes.js | 2 + src/services/authFlow/AuthFlow.js | 2 + 13 files changed, 163 insertions(+), 57 deletions(-) create mode 100644 src/components/accounts/AccountSwitcher.intl.json create mode 100644 src/components/auth/chooseAccount/ChooseAccount.intl.json create mode 100644 src/components/auth/chooseAccount/ChooseAccount.jsx create mode 100644 src/components/auth/chooseAccount/ChooseAccountBody.jsx create mode 100644 src/components/auth/chooseAccount/chooseAccount.scss 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 index e6b3b98..0ce99fb 100644 --- a/src/components/accounts/AccountSwitcher.jsx +++ b/src/components/accounts/AccountSwitcher.jsx @@ -1,6 +1,12 @@ import React, { Component, PropTypes } from 'react'; +import classNames from 'classnames'; +import { FormattedMessage as Message } from 'react-intl'; + +import { skins, SKIN_DARK } from 'components/ui'; + import styles from './accountSwitcher.scss'; +import messages from './AccountSwitcher.intl.json'; const accounts = { active: {id: 7, username: 'SleepWalker', email: 'danilenkos@auroraglobal.com'}, @@ -13,27 +19,65 @@ const accounts = { }; export default class AccountSwitcher extends Component { + static displayName = 'AccountSwitcher'; + + static propTypes = { + accounts: PropTypes.shape({ // TODO: accounts shape + active: PropTypes.shape({ + id: PropTypes.number + }), + available: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.number + })) + }), + skin: PropTypes.oneOf(skins), + hightLightActiveAccount: 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, + accounts + }; + 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.username} +
+
+ {accounts.active.email} +
+ + + + + +
-
- {accounts.active.email} -
- - Перейти в профиль Ely.by - - - Выйти -
-
- {accounts.available.map((account) => ( + ) : null} + {available.map((account) => (
@@ -44,34 +88,23 @@ export default class AccountSwitcher extends Component { {account.email}
-
+ {allowLogout ? ( +
+ ) : ( +
+ )}
))} -
+ {allowAdd ? (
- + - Добавить аккаунт +
+ + + +
-
+ ) : null}
); } } -/* -import { intlShape } from 'react-intl'; - -import messages from './LoggedInPanel.intl.json'; - - - static contextTypes = { - intl: intlShape.isRequired - }; - - */ diff --git a/src/components/accounts/accountSwitcher.scss b/src/components/accounts/accountSwitcher.scss index b408d2d..ccc8ee6 100644 --- a/src/components/accounts/accountSwitcher.scss +++ b/src/components/accounts/accountSwitcher.scss @@ -1,6 +1,17 @@ +@import '~components/ui/colors.scss'; + .accountSwitcher { background: #fff; color: #444; + text-align: left; +} + +.lightAccountSwitcher { + background: #fff; +} + +.darkAccountSwitcher { + background: $black; } .logoutIcon { @@ -8,3 +19,9 @@ color: #cdcdcd; } + +.nextIcon { + composes: arrowRight from 'components/ui/icons.scss'; + + color: #cdcdcd; +} diff --git a/src/components/auth/PanelTransition.jsx b/src/components/auth/PanelTransition.jsx index 0544005..2311515 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') { 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/chooseAccount/ChooseAccount.intl.json b/src/components/auth/chooseAccount/ChooseAccount.intl.json new file mode 100644 index 0000000..c32f22c --- /dev/null +++ b/src/components/auth/chooseAccount/ChooseAccount.intl.json @@ -0,0 +1,5 @@ +{ + "chooseAccountTitle": "Choose an account", + "addAccount": "Log into another account", + "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..1c95553 --- /dev/null +++ b/src/components/auth/chooseAccount/ChooseAccount.jsx @@ -0,0 +1,12 @@ +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 + } +}); + diff --git a/src/components/auth/chooseAccount/ChooseAccountBody.jsx b/src/components/auth/chooseAccount/ChooseAccountBody.jsx new file mode 100644 index 0000000..53415dd --- /dev/null +++ b/src/components/auth/chooseAccount/ChooseAccountBody.jsx @@ -0,0 +1,36 @@ +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 {user} = this.context; + this.context.auth.client = {name: 'foo'}; // TODO: remove me + const {client} = this.context.auth; + + return ( +
+ {this.renderErrors()} + +
+ {client.name} + }} /> +
+ +
+ +
+
+ ); + } +} diff --git a/src/components/auth/chooseAccount/chooseAccount.scss b/src/components/auth/chooseAccount/chooseAccount.scss new file mode 100644 index 0000000..7c652e5 --- /dev/null +++ b/src/components/auth/chooseAccount/chooseAccount.scss @@ -0,0 +1,6 @@ +.accountSwitcherContainer { +} + +.appName { + color: #fff; +} diff --git a/src/components/userbar/LoggedInPanel.jsx b/src/components/userbar/LoggedInPanel.jsx index 4f7fb39..baade00 100644 --- a/src/components/userbar/LoggedInPanel.jsx +++ b/src/components/userbar/LoggedInPanel.jsx @@ -1,4 +1,4 @@ -import React, { Component, PropTypes } from 'react'; +import React, { Component } from 'react'; import classNames from 'classnames'; @@ -12,8 +12,7 @@ import { userShape } from 'components/user/User'; export default class LoggedInPanel extends Component { static displayName = 'LoggedInPanel'; static propTypes = { - user: userShape, - onLogout: PropTypes.func.isRequired + user: userShape }; render() { @@ -28,16 +27,10 @@ export default class LoggedInPanel extends Component {
- +
); } - - onLogout = (event) => { - event.preventDefault(); - - this.props.onLogout(); - }; } diff --git a/src/pages/root/RootPage.jsx b/src/pages/root/RootPage.jsx index 0f6beec..a335f5d 100644 --- a/src/pages/root/RootPage.jsx +++ b/src/pages/root/RootPage.jsx @@ -36,7 +36,6 @@ function RootPage(props) {
@@ -58,16 +57,12 @@ RootPage.propTypes = { pathname: PropTypes.string }).isRequired, children: PropTypes.element, - logout: PropTypes.func.isRequired, isPopupActive: PropTypes.bool.isRequired }; import { connect } from 'react-redux'; -import { logout } from 'components/user/actions'; export default connect((state) => ({ user: state.user, isPopupActive: state.popup.popups.length > 0 -}), { - logout -})(RootPage); +}))(RootPage); diff --git a/src/routes.js b/src/routes.js index b61ed33..b71b57f 100644 --- a/src/routes.js +++ b/src/routes.js @@ -17,6 +17,7 @@ import OAuthInit from 'components/auth/OAuthInit'; import Register from 'components/auth/register/Register'; import Login from 'components/auth/login/Login'; import Permissions from 'components/auth/permissions/Permissions'; +import ChooseAccount from 'components/auth/chooseAccount/ChooseAccount'; import Activation from 'components/auth/activation/Activation'; import ResendActivation from 'components/auth/resendActivation/ResendActivation'; import Password from 'components/auth/password/Password'; @@ -62,6 +63,7 @@ export default function routesFactory(store) { + diff --git a/src/services/authFlow/AuthFlow.js b/src/services/authFlow/AuthFlow.js index f23779a..a305a4d 100644 --- a/src/services/authFlow/AuthFlow.js +++ b/src/services/authFlow/AuthFlow.js @@ -152,6 +152,8 @@ export default class AuthFlow { this.setState(new ResendActivationState()); break; + case '/oauth/choose-account': + break; case '/': case '/login': case '/password': From ffef5088625ad184123d1dace0e07bf03752e6cc Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 6 Nov 2016 02:35:09 +0300 Subject: [PATCH 06/34] =?UTF-8?q?=D0=A1=D1=82=D0=B8=D0=BB=D0=B8=D0=B7?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D0=B0=D0=BA?= =?UTF-8?q?=D1=82=D0=B8=D0=B2=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=B0=D0=BA=D0=BA?= =?UTF-8?q?=D0=B0=D1=83=D1=82=D0=B0=20=D0=B2=20=D0=B2=D1=8B=D0=BF=D0=B0?= =?UTF-8?q?=D0=B4=D0=B0=D1=8E=D1=89=D0=B5=D0=BC=20=D0=BC=D0=B5=D0=BD=D1=8E?= =?UTF-8?q?=20=D1=8E=D0=B7=D0=B5=D1=80=D0=B1=D0=B0=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/accounts/AccountSwitcher.jsx | 30 +++++---- src/components/accounts/accountSwitcher.scss | 66 ++++++++++++++++++++ src/components/userbar/loggedInPanel.scss | 13 +++- src/icons/webfont/minecraft-character.svg | 5 ++ 4 files changed, 99 insertions(+), 15 deletions(-) create mode 100644 src/icons/webfont/minecraft-character.svg diff --git a/src/components/accounts/AccountSwitcher.jsx b/src/components/accounts/AccountSwitcher.jsx index 0ce99fb..30cde92 100644 --- a/src/components/accounts/AccountSwitcher.jsx +++ b/src/components/accounts/AccountSwitcher.jsx @@ -59,26 +59,32 @@ export default class AccountSwitcher extends Component { styles[`${skin}AccountSwitcher`], )}> {highlightActiveAccount ? ( -
-
-
-
+
+
+
+
{accounts.active.username}
-
+
{accounts.active.email}
- - - - - - +
+
+ + + +
+
+ + + +
+
) : null} {available.map((account) => ( -
+
diff --git a/src/components/accounts/accountSwitcher.scss b/src/components/accounts/accountSwitcher.scss index ccc8ee6..455ea61 100644 --- a/src/components/accounts/accountSwitcher.scss +++ b/src/components/accounts/accountSwitcher.scss @@ -1,9 +1,17 @@ @import '~components/ui/colors.scss'; +@import '~components/ui/fonts.scss'; + +$borderColor: #EEE; .accountSwitcher { background: #fff; color: #444; text-align: left; + width: 205px; + + $border: 1px solid $borderColor; + border-left: $border; + border-right: $border; } .lightAccountSwitcher { @@ -14,6 +22,64 @@ background: $black; } +.item { + padding: 15px; + border-bottom: 1px solid $borderColor; +} + +.accountIcon { + composes: minecraft-character from 'components/ui/icons.scss'; + + font-size: 40px; + color: $green; + float: left; +} + +.activeAccountInfo { + margin-left: 29px; +} + +.activeAccountUsername { + font-size: 20px; + line-height: normal; // button style override + color: $green; +} + +.activeAccountEmail { + font-size: 10px; + color: #999; + + line-height: normal; // button style override + font-family: $font-family-base; // button style override +} + +.links { + margin-top: 6px; +} + +.link { + line-height: normal; // button style override + font-size: 12px; // button style override + 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: #777; + color: #777; + } + } +} + .logoutIcon { composes: exit from 'components/ui/icons.scss'; diff --git a/src/components/userbar/loggedInPanel.scss b/src/components/userbar/loggedInPanel.scss index 37f9d77..69fb816 100644 --- a/src/components/userbar/loggedInPanel.scss +++ b/src/components/userbar/loggedInPanel.scss @@ -1,9 +1,15 @@ +@import '~components/ui/colors.scss'; + .loggedInPanel { } .activeAccount { position: relative; + $border: 1px solid rgba(#fff, .15); + border-left: $border; + border-right: $border; + &:hover { .accountSwitcherContainer { display: block; @@ -21,10 +27,11 @@ } .expandIcon { - composes: arrow from 'components/ui/icons.scss'; + composes: caret from 'components/ui/icons.scss'; - margin-left: 5px; - font-size: 12px; + margin-left: 4px; + font-size: 6px; + color: #CCC; } .userName { diff --git a/src/icons/webfont/minecraft-character.svg b/src/icons/webfont/minecraft-character.svg new file mode 100644 index 0000000..2e0711a --- /dev/null +++ b/src/icons/webfont/minecraft-character.svg @@ -0,0 +1,5 @@ + + + + + From 31538efa698b854c28aa56a9fc5dad78d29d0db0 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 6 Nov 2016 15:58:38 +0300 Subject: [PATCH 07/34] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=BF=D0=BB=D0=B0=D0=B3=D0=B8=D0=BD=20=D0=B4?= =?UTF-8?q?=D0=BB=D1=8F=20=D0=B4=D0=B5=D1=82=D0=B5=D0=BA=D1=82=D0=B0=20?= =?UTF-8?q?=D1=86=D0=B8=D0=BA=D0=BB=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D1=85=20=D0=B7=D0=B0=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 1 + webpack.config.js | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index c7bb99d..d586638 100644 --- a/package.json +++ b/package.json @@ -51,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/webpack.config.js b/webpack.config.js index 2fa1959..d0a4a70 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -6,6 +6,7 @@ const webpack = require('webpack'); const loaderUtils = require('loader-utils'); const ExtractTextPlugin = require('extract-text-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); +const CircularDependencyPlugin = require('circular-dependency-plugin'); const cssUrl = require('webpack-utils/cssUrl'); const cssImport = require('postcss-import'); @@ -247,7 +248,11 @@ if (isProduction) { if (!isProduction && !isTest) { webpackConfig.plugins.push( new webpack.HotModuleReplacementPlugin(), - new webpack.NoErrorsPlugin() + new webpack.NoErrorsPlugin(), + new CircularDependencyPlugin({ + exclude: /node_modules/, + failOnError: false + }) ); if (config.apiHost) { From e48fca63b90c53bbe390e5ab0cb050b3f13233c5 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sun, 6 Nov 2016 17:19:52 +0300 Subject: [PATCH 08/34] =?UTF-8?q?=D0=94=D0=BE=D0=B2=D1=91=D1=80=D1=81?= =?UTF-8?q?=D1=82=D0=B0=D0=BD=D1=8B=20=D1=81=D1=82=D0=B8=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=B9=20=D0=B0=D0=BA=D0=BA?= =?UTF-8?q?=D0=B0=D1=83=D0=BD=D1=82=D0=BE=D0=B2=20=D0=98=D0=B7=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D1=8B=20=D1=86=D0=B2=D0=B5=D1=82=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D1=87=D1=91=D1=80=D0=BD=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8=20=D0=9E=D0=B1=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20=D1=81=D1=82=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=B2=D0=B5=D1=82=D0=BB=D0=BE?= =?UTF-8?q?=D0=B9=20=D0=BA=D0=BD=D0=BE=D0=BF=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/accounts/AccountSwitcher.jsx | 66 +++-- src/components/accounts/accountSwitcher.scss | 263 ++++++++++++++---- .../auth/chooseAccount/ChooseAccount.jsx | 1 - .../auth/chooseAccount/ChooseAccountBody.jsx | 2 +- .../auth/chooseAccount/chooseAccount.scss | 17 ++ src/components/langMenu/langMenu.scss | 2 +- src/components/ui/buttons.scss | 12 +- src/components/ui/colors.scss | 4 +- src/components/ui/form/Button.jsx | 8 +- src/components/ui/index.js | 6 +- src/components/userbar/loggedInPanel.scss | 2 +- src/icons/webfont/plus.svg | 7 + 12 files changed, 295 insertions(+), 95 deletions(-) create mode 100644 src/icons/webfont/plus.svg diff --git a/src/components/accounts/AccountSwitcher.jsx b/src/components/accounts/AccountSwitcher.jsx index 30cde92..d700bc2 100644 --- a/src/components/accounts/AccountSwitcher.jsx +++ b/src/components/accounts/AccountSwitcher.jsx @@ -3,7 +3,8 @@ import React, { Component, PropTypes } from 'react'; import classNames from 'classnames'; import { FormattedMessage as Message } from 'react-intl'; -import { skins, SKIN_DARK } from 'components/ui'; +import { skins, SKIN_DARK, COLOR_LIGHT } from 'components/ui'; +import { Button } from 'components/ui/form'; import styles from './accountSwitcher.scss'; import messages from './AccountSwitcher.intl.json'; @@ -12,9 +13,9 @@ const accounts = { active: {id: 7, username: 'SleepWalker', email: 'danilenkos@auroraglobal.com'}, available: [ {id: 7, username: 'SleepWalker', email: 'danilenkos@auroraglobal.com'}, - {id: 8, username: 'ErickSkrauch', email: 'erick@foo.bar'}, - {id: 9, username: 'Ely-en', email: 'ely@-enfoo.bar'}, - {id: 10, username: 'Ely-by', email: 'ely-by@foo.bar'}, + {id: 8, username: 'ErickSkrauch', email: 'erickskrauch@yandex.ru'}, + {id: 9, username: 'Ely-en', email: 'ely-en@ely.by'}, + {id: 10, username: 'Ely-by', email: 'ely-pt@ely.by'}, ] }; @@ -31,7 +32,7 @@ export default class AccountSwitcher extends Component { })) }), skin: PropTypes.oneOf(skins), - hightLightActiveAccount: PropTypes.bool, // whether active account should be expanded and shown on the top + 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 }; @@ -60,12 +61,16 @@ export default class AccountSwitcher extends Component { )}> {highlightActiveAccount ? (
-
+
{accounts.active.username}
-
+
{accounts.active.email}
@@ -83,30 +88,47 @@ export default class AccountSwitcher extends Component {
) : null} - {available.map((account) => ( -
-
-
-
- {account.username} -
-
- {account.email} -
-
+ {available.map((account, id) => ( +
+
+ {allowLogout ? (
) : (
)} + +
+
+ {account.username} +
+
+ {account.email} +
+
))} {allowAdd ? (
-
- + - -
+
diff --git a/src/components/accounts/accountSwitcher.scss b/src/components/accounts/accountSwitcher.scss index 455ea61..6cd797a 100644 --- a/src/components/accounts/accountSwitcher.scss +++ b/src/components/accounts/accountSwitcher.scss @@ -1,82 +1,228 @@ @import '~components/ui/colors.scss'; @import '~components/ui/fonts.scss'; -$borderColor: #EEE; +// TODO: эту константу можно заимпортить из panel.scss, но это приводит к странным ошибкам +//@import '~components/ui/panel.scss'; +$bodyLeftRightPadding: 20px; + +$lightBorderColor: #EEE; .accountSwitcher { - background: #fff; - color: #444; text-align: left; - width: 205px; +} - $border: 1px solid $borderColor; - border-left: $border; - border-right: $border; +.accountInfo { + +} + +.accountUsername { + line-height: normal; // button style override +} + +.accountEmail { + line-height: normal; // button style override + font-family: $font-family-base; // button style override } .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: $lightButtonLight; + } + + &:active { + background-color: $lightButtonDark; + } + } + + .accountIcon { + font-size: 27px; + width: 20px; + text-align: center; + } + + .activeAccountIcon { + font-size: 40px; + } + + .activeAccountInfo { + margin-left: 29px; + } + + .activeAccountUsername { + font-size: 20px; + line-height: normal; // button style override + color: $green; + } + + .activeAccountEmail { + } + + .links { + margin-top: 6px; + } + + .link { + line-height: normal; // button style override + font-size: 12px; // button style override + 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 { + color: #666; + font-size: 14px; + } + + .accountEmail { + font-size: 10px; + color: #999; + } + + .addAccount { + } } .darkAccountSwitcher { background: $black; -} -.item { - padding: 15px; - border-bottom: 1px solid $borderColor; + $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 { + color: #fff; + } + + .accountEmail { + color: #666; + font-size: 12px; + } } .accountIcon { composes: minecraft-character from 'components/ui/icons.scss'; - font-size: 40px; - color: $green; float: left; -} -.activeAccountInfo { - margin-left: 29px; -} - -.activeAccountUsername { - font-size: 20px; - line-height: normal; // button style override - color: $green; -} - -.activeAccountEmail { - font-size: 10px; - color: #999; - - line-height: normal; // button style override - font-family: $font-family-base; // button style override -} - -.links { - margin-top: 6px; -} - -.link { - line-height: normal; // button style override - font-size: 12px; // button style override - margin-bottom: 3px; - - &:last-of-type { - margin-bottom: 0; + &1 { + color: $green; } - a { - color: #666; - font-size: 12px; - border-bottom: 1px dotted #666; - text-decoration: none; - transition: .25s; + &2 { + color: $blue; + } - &:hover { - border-bottom-color: #777; - color: #777; - } + &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; } } @@ -84,10 +230,11 @@ $borderColor: #EEE; composes: exit from 'components/ui/icons.scss'; color: #cdcdcd; -} + float: right; + line-height: 27px; + transition: .25s; -.nextIcon { - composes: arrowRight from 'components/ui/icons.scss'; - - color: #cdcdcd; + &:hover { + color: #777; + } } diff --git a/src/components/auth/chooseAccount/ChooseAccount.jsx b/src/components/auth/chooseAccount/ChooseAccount.jsx index 1c95553..b220f7d 100644 --- a/src/components/auth/chooseAccount/ChooseAccount.jsx +++ b/src/components/auth/chooseAccount/ChooseAccount.jsx @@ -9,4 +9,3 @@ export default factory({ label: messages.addAccount } }); - diff --git a/src/components/auth/chooseAccount/ChooseAccountBody.jsx b/src/components/auth/chooseAccount/ChooseAccountBody.jsx index 53415dd..a09f95d 100644 --- a/src/components/auth/chooseAccount/ChooseAccountBody.jsx +++ b/src/components/auth/chooseAccount/ChooseAccountBody.jsx @@ -21,7 +21,7 @@ export default class ChooseAccountBody extends BaseAuthBody {
{this.renderErrors()} -
+
{client.name} }} /> diff --git a/src/components/auth/chooseAccount/chooseAccount.scss b/src/components/auth/chooseAccount/chooseAccount.scss index 7c652e5..be1319a 100644 --- a/src/components/auth/chooseAccount/chooseAccount.scss +++ b/src/components/auth/chooseAccount/chooseAccount.scss @@ -1,4 +1,21 @@ +//@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 { diff --git a/src/components/langMenu/langMenu.scss b/src/components/langMenu/langMenu.scss index 45321d8..4f37506 100644 --- a/src/components/langMenu/langMenu.scss +++ b/src/components/langMenu/langMenu.scss @@ -56,7 +56,7 @@ transition: .2s; &:hover { - background: #f5f5f5; + background: $lightButtonLight; color: #262626; } diff --git a/src/components/ui/buttons.scss b/src/components/ui/buttons.scss index 7f4b925..e68b3e0 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 { +.light { composes: button; - background-color: $black; + background-color: #fff; + color: #444; &:hover { - background-color: $black-button-light; + color: #262626; + background-color: $lightButtonLight; } &:active { - background-color: $black-button-dark; + background-color: $lightButtonDark; } } +@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..1020f0b 100644 --- a/src/components/ui/colors.scss +++ b/src/components/ui/colors.scss @@ -10,8 +10,8 @@ $light: #ebe8e1; $black: #232323; $defaultButtonTextColor : #fff; -$black-button-light: #392f2c; -$black-button-dark: #1e0b11; +$lightButtonLight: #f5f5f5; +$lightButtonDark: #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 {
))} {allowAdd ? ( -
+
+ ) : null}
); diff --git a/src/components/accounts/accountSwitcher.scss b/src/components/accounts/accountSwitcher.scss index 71ac16e..b6b6c17 100644 --- a/src/components/accounts/accountSwitcher.scss +++ b/src/components/accounts/accountSwitcher.scss @@ -5,7 +5,7 @@ //@import '~components/ui/panel.scss'; $bodyLeftRightPadding: 20px; -$lightBorderColor: #EEE; +$lightBorderColor: #eee; .accountSwitcher { text-align: left; @@ -16,12 +16,9 @@ $lightBorderColor: #EEE; } .accountUsername { - line-height: normal; // button style override } .accountEmail { - line-height: normal; // button style override - font-family: $font-family-base; // button style override } .lightAccountSwitcher { @@ -68,7 +65,6 @@ $lightBorderColor: #EEE; .activeAccountUsername { font-size: 20px; - line-height: normal; // button style override color: $green; } @@ -80,8 +76,7 @@ $lightBorderColor: #EEE; } .link { - line-height: normal; // button style override - font-size: 12px; // button style override + font-size: 12px; margin-bottom: 3px; &:last-of-type { diff --git a/src/components/userbar/LoggedInPanel.jsx b/src/components/userbar/LoggedInPanel.jsx index baade00..8849351 100644 --- a/src/components/userbar/LoggedInPanel.jsx +++ b/src/components/userbar/LoggedInPanel.jsx @@ -1,4 +1,5 @@ import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; import classNames from 'classnames'; @@ -15,16 +16,32 @@ export default class LoggedInPanel extends Component { user: userShape }; + state = { + isAccountSwitcherActive: false + }; + + componentDidMount() { + document.addEventListener('click', this.onBodyClick); + } + + componentWillUnmount() { + document.removeEventListener('click', this.onBodyClick); + } + render() { const { user } = this.props; + const { isAccountSwitcherActive } = this.state; return (
- {/* this button must be a div, because some browsers force overflow hidden on button elements */} -
- - {user.username} - +
+
@@ -33,4 +50,51 @@ export default class LoggedInPanel extends Component {
); } + + toggleAccountSwitcher() { + this.setState({ + isAccountSwitcherActive: !this.state.isAccountSwitcherActive + }); + } + + onExpandAccountSwitcher = (event) => { + event.preventDefault(); + + this.toggleAccountSwitcher(); + }; + + onBodyClick = createOnOutsideComponentClickHandler( + () => ReactDOM.findDOMNode(this), + () => this.state.isAccountSwitcherActive, + () => this.toggleAccountSwitcher() + ); +} + +/** + * Creates an event handling function to handle clicks outside the component + * + * The handler will check if current click was outside container el and if so + * and component isActive, it will call the callback + * + * @param {function} getEl - the function, that returns reference to container el + * @param {function} isActive - whether the component is active and callback may be called + * @param {function} callback - the callback to call, when there was a click outside el + * @return {function} + */ +function createOnOutsideComponentClickHandler(getEl, isActive, callback) { + // TODO: we have the same logic in LangMenu + // Probably we should decouple this into some helper function + // TODO: the name of function may be better... + return (event) => { + if (isActive()) { + const el = getEl(); + + if (!el.contains(event.target) && el !== event.taget) { + event.preventDefault(); + + // add a small delay for the case someone have alredy called toggle + setTimeout(() => isActive() && callback(), 0); + } + } + }; } diff --git a/src/components/userbar/loggedInPanel.scss b/src/components/userbar/loggedInPanel.scss index 54c00e6..f4b3771 100644 --- a/src/components/userbar/loggedInPanel.scss +++ b/src/components/userbar/loggedInPanel.scss @@ -5,15 +5,20 @@ .activeAccount { position: relative; + display: inline-block; $border: 1px solid rgba(#fff, .15); border-left: $border; border-right: $border; +} - &:hover { - .accountSwitcherContainer { - display: block; - } +.activeAccountExpanded { + .accountSwitcherContainer { + display: block; + } + + .expandIcon { + transform: rotate(180deg); } } From f09e35627e90fbdcc2fa39898749c71c1c485a97 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Fri, 11 Nov 2016 11:23:55 +0300 Subject: [PATCH 12/34] =?UTF-8?q?=D0=A2=D1=91=D0=BC=D0=BD=D1=8B=D0=B9=20?= =?UTF-8?q?=D1=84=D0=BE=D0=BD=20=D0=B4=D0=BB=D1=8F=20=D0=BA=D0=BD=D0=BE?= =?UTF-8?q?=D0=BF=D0=BA=D0=B8=20=D1=80=D0=B0=D1=81=D0=BA=D1=80=D1=8B=D1=82?= =?UTF-8?q?=D0=B8=D1=8F=20=D1=8E=D0=B7=D0=B5=D1=80=D0=B1=D0=B0=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/userbar/LoggedInPanel.jsx | 2 +- src/components/userbar/loggedInPanel.scss | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/components/userbar/LoggedInPanel.jsx b/src/components/userbar/LoggedInPanel.jsx index 8849351..fd7a33b 100644 --- a/src/components/userbar/LoggedInPanel.jsx +++ b/src/components/userbar/LoggedInPanel.jsx @@ -37,7 +37,7 @@ export default class LoggedInPanel extends Component {
-
+ )} />
{accounts.active.username} @@ -81,7 +74,7 @@ export default class AccountSwitcher extends Component {
@@ -90,16 +83,19 @@ export default class AccountSwitcher extends Component {
) : null} {available.map((account, id) => ( -
+
+ )} /> {allowLogout ? ( -
+
) : ( -
+
)}
@@ -113,7 +109,7 @@ export default class AccountSwitcher extends Component {
))} {allowAdd ? ( - +
- +
); } - toggleAccountSwitcher() { - this.setState({ - isAccountSwitcherActive: !this.state.isAccountSwitcherActive - }); - } + toggleAccountSwitcher = () => this.setState({ + isAccountSwitcherActive: !this.state.isAccountSwitcherActive + }); onExpandAccountSwitcher = (event) => { event.preventDefault(); diff --git a/src/services/authFlow/LoginState.js b/src/services/authFlow/LoginState.js index 5ee0fb0..bbb5c5a 100644 --- a/src/services/authFlow/LoginState.js +++ b/src/services/authFlow/LoginState.js @@ -3,11 +3,15 @@ import PasswordState from './PasswordState'; export default class LoginState extends AbstractState { enter(context) { - const {user} = context.getState(); + const {auth, user} = context.getState(); - if (user.email || user.username) { + // TODO: it may not allow user to leave password state till he click back or enters password + if (auth.login) { context.setState(new PasswordState()); - } else { + } else if (user.isGuest + // for the case, when user is logged in and wants to add a new aacount + || /login|password/.test(context.getRequest().path) // TODO: improve me + ) { context.navigate('/login'); } } diff --git a/src/services/authFlow/PasswordState.js b/src/services/authFlow/PasswordState.js index 13e6c22..039f54b 100644 --- a/src/services/authFlow/PasswordState.js +++ b/src/services/authFlow/PasswordState.js @@ -5,9 +5,9 @@ import LoginState from './LoginState'; export default class PasswordState extends AbstractState { enter(context) { - const {user} = context.getState(); + const {auth} = context.getState(); - if (user.isGuest) { + if (auth.login) { context.navigate('/password'); } else { context.setState(new CompleteState()); @@ -15,12 +15,12 @@ export default class PasswordState extends AbstractState { } resolve(context, {password, rememberMe}) { - const {user} = context.getState(); + const {auth: {login}} = context.getState(); context.run('login', { password, rememberMe, - login: user.email || user.username + login }) .then(() => context.setState(new CompleteState())); } @@ -30,7 +30,7 @@ export default class PasswordState extends AbstractState { } goBack(context) { - context.run('logout'); + context.run('setLogin', null); context.setState(new LoginState()); } } diff --git a/tests/components/accounts/actions.test.js b/tests/components/accounts/actions.test.js index 94d363f..fffab50 100644 --- a/tests/components/accounts/actions.test.js +++ b/tests/components/accounts/actions.test.js @@ -22,7 +22,7 @@ const user = { lang: 'be' }; -describe('Accounts actions', () => { +describe('components/accounts/actions', () => { let dispatch; let getState; @@ -109,19 +109,15 @@ describe('Accounts actions', () => { }); describe('#revoke()', () => { - it(`should dispatch ${REMOVE} action`, () => { - revoke(account)(dispatch, getState); - - expect(dispatch, 'to have a call satisfying', [ - remove(account) - ]); - }); - it('should switch next account if available', () => { const account2 = {...account, id: 2}; getState.returns({ - accounts: [account] + accounts: { + active: account2, + available: [account] + }, + user }); return revoke(account2)(dispatch, getState).then(() => { @@ -143,10 +139,15 @@ describe('Accounts actions', () => { }); it('should logout if no other accounts available', () => { + getState.returns({ + accounts: { + active: account, + available: [] + }, + user + }); + revoke(account)(dispatch, getState).then(() => { - expect(dispatch, 'to have a call satisfying', [ - remove(account) - ]); expect(dispatch, 'to have a call satisfying', [ {payload: {isGuest: true}} // updateUser({isGuest: true}) diff --git a/tests/components/accounts/reducer.test.js b/tests/components/accounts/reducer.test.js index 58ef367..3769870 100644 --- a/tests/components/accounts/reducer.test.js +++ b/tests/components/accounts/reducer.test.js @@ -45,11 +45,23 @@ describe('Accounts reducer', () => { }) ); - it('should not add the same account twice', () => - expect(accounts({...initial, available: [account]}, 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' + }; + + return expect( + accounts({...initial, available: [outdatedAccount]}, add(updatedAccount)), + 'to satisfy', { + available: [updatedAccount] + }); + }); it('throws, when account is invalid', () => { expect(() => accounts(initial, add()), diff --git a/tests/components/auth/actions.test.js b/tests/components/auth/actions.test.js index 4c7ff9f..8afd00b 100644 --- a/tests/components/auth/actions.test.js +++ b/tests/components/auth/actions.test.js @@ -10,7 +10,9 @@ import { setOAuthRequest, setScopes, setOAuthCode, - requirePermissionsAccept + requirePermissionsAccept, + login, + setLogin } from 'components/auth/actions'; const oauthData = { @@ -22,8 +24,8 @@ const oauthData = { }; describe('components/auth/actions', () => { - const dispatch = sinon.stub().named('dispatch'); - const getState = sinon.stub().named('getState'); + const dispatch = sinon.stub().named('store.dispatch'); + const getState = sinon.stub().named('store.getState'); function callThunk(fn, ...args) { const thunk = fn(...args); @@ -67,21 +69,21 @@ describe('components/auth/actions', () => { request.get.returns(Promise.resolve(resp)); }); - it('should send get request to an api', () => { - return callThunk(oAuthValidate, oauthData).then(() => { + 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', () => { - return callThunk(oAuthValidate, oauthData).then(() => { + it('should dispatch setClient, setOAuthRequest and setScopes', () => + callThunk(oAuthValidate, oauthData).then(() => { expectDispatchCalls([ [setClient(resp.client)], [setOAuthRequest(resp.oAuth)], [setScopes(resp.session.scopes)] ]); - }); - }); + }) + ); }); describe('#oAuthComplete()', () => { @@ -160,4 +162,24 @@ describe('components/auth/actions', () => { }); }); }); + + 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')] + ]); + }) + ); + }); + }); }); diff --git a/tests/components/auth/reducer.test.js b/tests/components/auth/reducer.test.js new file mode 100644 index 0000000..66024a9 --- /dev/null +++ b/tests/components/auth/reducer.test.js @@ -0,0 +1,16 @@ +import expect from 'unexpected'; + +import auth from 'components/auth/reducer'; +import { setLogin, SET_LOGIN } from 'components/auth/actions'; + +describe('auth reducer', () => { + describe(SET_LOGIN, () => { + it('should set login', () => { + const expectedLogin = 'foo'; + + expect(auth(undefined, setLogin(expectedLogin)), 'to satisfy', { + login: expectedLogin + }); + }); + }); +}); diff --git a/tests/components/user/middlewares/refreshTokenMiddleware.test.js b/tests/components/user/middlewares/refreshTokenMiddleware.test.js index 82b1114..6068872 100644 --- a/tests/components/user/middlewares/refreshTokenMiddleware.test.js +++ b/tests/components/user/middlewares/refreshTokenMiddleware.test.js @@ -68,7 +68,7 @@ describe('refreshTokenMiddleware', () => { }); it('should not apply to refresh-token request', () => { - const data = {url: '/refresh-token'}; + const data = {url: '/refresh-token', options: {}}; const resp = middleware.before(data); expect(resp, 'to satisfy', data); diff --git a/tests/services/authFlow/AuthFlow.functional.test.js b/tests/services/authFlow/AuthFlow.functional.test.js index 9d135d5..fa281dc 100644 --- a/tests/services/authFlow/AuthFlow.functional.test.js +++ b/tests/services/authFlow/AuthFlow.functional.test.js @@ -47,6 +47,9 @@ describe('AuthFlow.functional', () => { state.user = { isGuest: true }; + state.auth = { + login: null + }; }); it('should redirect guest / -> /login', () => { diff --git a/tests/services/authFlow/LoginState.test.js b/tests/services/authFlow/LoginState.test.js index 60ad0b3..54602a5 100644 --- a/tests/services/authFlow/LoginState.test.js +++ b/tests/services/authFlow/LoginState.test.js @@ -1,6 +1,5 @@ import LoginState from 'services/authFlow/LoginState'; import PasswordState from 'services/authFlow/PasswordState'; -import ForgotPasswordState from 'services/authFlow/ForgotPasswordState'; import { bootstrap, expectState, expectNavigate, expectRun } from './helpers'; @@ -24,7 +23,8 @@ describe('LoginState', () => { describe('#enter', () => { it('should navigate to /login', () => { context.getState.returns({ - user: {isGuest: true} + user: {isGuest: true}, + auth: {login: null} }); expectNavigate(mock, '/login'); @@ -32,22 +32,15 @@ describe('LoginState', () => { state.enter(context); }); - const testTransitionToPassword = (user) => { + it('should transition to password if login was set', () => { context.getState.returns({ - user: user + user: {isGuest: true}, + auth: {login: 'foo'} }); expectState(mock, PasswordState); state.enter(context); - }; - - it('should transition to password if has email', () => { - testTransitionToPassword({email: 'foo'}); - }); - - it('should transition to password if has username', () => { - testTransitionToPassword({username: 'foo'}); }); }); diff --git a/tests/services/authFlow/PasswordState.test.js b/tests/services/authFlow/PasswordState.test.js index 5a003de..b92578a 100644 --- a/tests/services/authFlow/PasswordState.test.js +++ b/tests/services/authFlow/PasswordState.test.js @@ -25,7 +25,8 @@ describe('PasswordState', () => { describe('#enter', () => { it('should navigate to /password', () => { context.getState.returns({ - user: {isGuest: true} + user: {isGuest: true}, + auth: {login: 'foo'} }); expectNavigate(mock, '/password'); @@ -35,7 +36,8 @@ describe('PasswordState', () => { it('should transition to complete if not guest', () => { context.getState.returns({ - user: {isGuest: false} + user: {isGuest: false}, + auth: {login: null} }); expectState(mock, CompleteState); @@ -45,42 +47,29 @@ describe('PasswordState', () => { }); describe('#resolve', () => { - (function() { - const expectedLogin = 'login'; - const expectedPassword = 'password'; + it('should call login with login and password', () => { + const expectedLogin = 'foo'; + const expectedPassword = 'bar'; const expectedRememberMe = true; - const testWith = (user) => { - it(`should call login with email or username and password. User: ${JSON.stringify(user)}`, () => { - context.getState.returns({user}); - - expectRun( - mock, - 'login', - sinon.match({ - login: expectedLogin, - password: expectedPassword, - rememberMe: expectedRememberMe, - }) - ).returns({then() {}}); - - state.resolve(context, {password: expectedPassword, rememberMe: expectedRememberMe}); - }); - }; - - testWith({ - email: expectedLogin + context.getState.returns({ + auth: { + login: expectedLogin + } }); - testWith({ - username: expectedLogin - }); + expectRun( + mock, + 'login', + sinon.match({ + login: expectedLogin, + password: expectedPassword, + rememberMe: expectedRememberMe, + }) + ).returns({then() {}}); - testWith({ - email: expectedLogin, - username: expectedLogin - }); - }()); + state.resolve(context, {password: expectedPassword, rememberMe: expectedRememberMe}); + }); it('should transition to complete state on successfull login', () => { const promise = Promise.resolve(); @@ -88,8 +77,8 @@ describe('PasswordState', () => { const expectedPassword = 'password'; context.getState.returns({ - user: { - email: expectedLogin + auth: { + login: expectedLogin } }); @@ -111,8 +100,8 @@ describe('PasswordState', () => { }); describe('#goBack', () => { - it('should transition to forgot password state', () => { - expectRun(mock, 'logout'); + it('should transition to login state', () => { + expectRun(mock, 'setLogin', null); expectState(mock, LoginState); state.goBack(context); From 420ce65392410f2d8fdef7ad8f07ae0be1ac2a07 Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 12 Nov 2016 22:42:12 +0200 Subject: [PATCH 15/34] #48: style fixes --- src/components/accounts/accountSwitcher.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/accounts/accountSwitcher.scss b/src/components/accounts/accountSwitcher.scss index 339013d..dc3d08c 100644 --- a/src/components/accounts/accountSwitcher.scss +++ b/src/components/accounts/accountSwitcher.scss @@ -15,10 +15,10 @@ $lightBorderColor: #eee; } -.accountUsername { -} - +.accountUsername, .accountEmail { + overflow: hidden; + text-overflow: ellipsis; } .lightAccountSwitcher { From 81a5437be0d4132411b9b994d3571a009a497e91 Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sun, 13 Nov 2016 14:16:21 +0200 Subject: [PATCH 16/34] #48: add account sorting --- src/components/accounts/reducer.js | 8 ++++++++ tests/components/accounts/reducer.test.js | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/components/accounts/reducer.js b/src/components/accounts/reducer.js index 1eb225a..2b69268 100644 --- a/src/components/accounts/reducer.js +++ b/src/components/accounts/reducer.js @@ -30,6 +30,14 @@ export default function accounts( .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: diff --git a/tests/components/accounts/reducer.test.js b/tests/components/accounts/reducer.test.js index 3769870..4729aa6 100644 --- a/tests/components/accounts/reducer.test.js +++ b/tests/components/accounts/reducer.test.js @@ -56,13 +56,26 @@ describe('Accounts reducer', () => { token: 'newToken' }; - return expect( + 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'); From b6b8468904ee695959b1783e918b1068d941dfcb Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sun, 13 Nov 2016 16:47:56 +0200 Subject: [PATCH 17/34] #48: account switcher for oauth --- src/components/accounts/AccountSwitcher.jsx | 7 +++-- src/components/auth/PanelTransition.jsx | 10 ++---- src/components/auth/actions.js | 8 +++++ .../auth/chooseAccount/ChooseAccountBody.jsx | 13 ++++++-- src/components/auth/reducer.js | 20 +++++++++++- src/services/authFlow/AuthFlow.js | 3 +- src/services/authFlow/ChooseAccountState.js | 20 ++++++++++++ src/services/authFlow/CompleteState.js | 5 ++- src/services/authFlow/LoginState.js | 2 ++ tests/components/auth/reducer.test.js | 31 +++++++++++++++++-- tests/services/authFlow/AuthFlow.test.js | 1 + 11 files changed, 101 insertions(+), 19 deletions(-) create mode 100644 src/services/authFlow/ChooseAccountState.js diff --git a/src/components/accounts/AccountSwitcher.jsx b/src/components/accounts/AccountSwitcher.jsx index 428886f..856a939 100644 --- a/src/components/accounts/AccountSwitcher.jsx +++ b/src/components/accounts/AccountSwitcher.jsx @@ -17,6 +17,7 @@ export class AccountSwitcher extends Component { 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 @@ -36,7 +37,8 @@ export class AccountSwitcher extends Component { highlightActiveAccount: true, allowLogout: true, allowAdd: true, - onAfterAction() {} + onAfterAction() {}, + onSwitch() {} }; render() { @@ -136,7 +138,8 @@ export class AccountSwitcher extends Component { event.preventDefault(); this.props.switchAccount(account) - .then(() => this.props.onAfterAction()); + .then(() => this.props.onAfterAction()) + .then(() => this.props.onSwitch(account)); }; onRemove = (account) => (event) => { diff --git a/src/components/auth/PanelTransition.jsx b/src/components/auth/PanelTransition.jsx index 1e2d795..8d09d73 100644 --- a/src/components/auth/PanelTransition.jsx +++ b/src/components/auth/PanelTransition.jsx @@ -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,10 +86,7 @@ class PanelTransition extends Component { type: PropTypes.string, payload: PropTypes.object })]), - login: PropTypes.shape({ - login: PropTypes.string, - password: PropTypes.string - }) + login: PropTypes.string }), user: userShape, requestRedraw: PropTypes.func, diff --git a/src/components/auth/actions.js b/src/components/auth/actions.js index 1191b65..5642904 100644 --- a/src/components/auth/actions.js +++ b/src/components/auth/actions.js @@ -122,6 +122,14 @@ export function setLogin(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 { diff --git a/src/components/auth/chooseAccount/ChooseAccountBody.jsx b/src/components/auth/chooseAccount/ChooseAccountBody.jsx index a09f95d..b80ba1b 100644 --- a/src/components/auth/chooseAccount/ChooseAccountBody.jsx +++ b/src/components/auth/chooseAccount/ChooseAccountBody.jsx @@ -13,8 +13,6 @@ export default class ChooseAccountBody extends BaseAuthBody { static panelId = 'chooseAccount'; render() { - const {user} = this.context; - this.context.auth.client = {name: 'foo'}; // TODO: remove me const {client} = this.context.auth; return ( @@ -28,9 +26,18 @@ export default class ChooseAccountBody extends BaseAuthBody {
- +
); } + + onSwitch = (account) => { + this.context.resolve(account); + }; } diff --git a/src/components/auth/reducer.js b/src/components/auth/reducer.js index 0c884bf..4d9cee3 100644 --- a/src/components/auth/reducer.js +++ b/src/components/auth/reducer.js @@ -8,13 +8,15 @@ import { SET_SCOPES, SET_LOADING_STATE, REQUIRE_PERMISSIONS_ACCEPT, - SET_LOGIN + SET_LOGIN, + SET_SWITCHER } from './actions'; export default combineReducers({ login, error, isLoading, + isSwitcherEnabled, client, oauth, scopes @@ -54,6 +56,22 @@ function login( } } +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, diff --git a/src/services/authFlow/AuthFlow.js b/src/services/authFlow/AuthFlow.js index a305a4d..32e22e0 100644 --- a/src/services/authFlow/AuthFlow.js +++ b/src/services/authFlow/AuthFlow.js @@ -152,14 +152,13 @@ export default class AuthFlow { this.setState(new ResendActivationState()); break; - case '/oauth/choose-account': - break; case '/': case '/login': case '/password': case '/accept-rules': case '/oauth/permissions': case '/oauth/finish': + case '/oauth/choose-account': this.setState(new LoginState()); break; diff --git a/src/services/authFlow/ChooseAccountState.js b/src/services/authFlow/ChooseAccountState.js new file mode 100644 index 0000000..069b3a6 --- /dev/null +++ b/src/services/authFlow/ChooseAccountState.js @@ -0,0 +1,20 @@ +import AbstractState from './AbstractState'; +import LoginState from './LoginState'; +import CompleteState from './CompleteState'; + +export default class ChooseAccountState extends AbstractState { + enter(context) { + context.navigate('/oauth/choose-account'); + } + + resolve(context, payload) { + context.run('setAccountSwitcher', false); + + if (payload.id) { + context.setState(new CompleteState()); + } else { + context.navigate('/login'); + context.setState(new LoginState()); + } + } +} diff --git a/src/services/authFlow/CompleteState.js b/src/services/authFlow/CompleteState.js index 248090f..bb4bc7d 100644 --- a/src/services/authFlow/CompleteState.js +++ b/src/services/authFlow/CompleteState.js @@ -1,6 +1,7 @@ import AbstractState from './AbstractState'; import LoginState from './LoginState'; import PermissionsState from './PermissionsState'; +import ChooseAccountState from './ChooseAccountState'; import ActivationState from './ActivationState'; import AcceptRulesState from './AcceptRulesState'; import FinishState from './FinishState'; @@ -22,7 +23,9 @@ export default class CompleteState extends AbstractState { } else if (user.shouldAcceptRules) { context.setState(new AcceptRulesState()); } else if (auth.oauth && auth.oauth.clientId) { - if (auth.oauth.code) { + if (auth.isSwitcherEnabled) { + context.setState(new ChooseAccountState()); + } else if (auth.oauth.code) { context.setState(new FinishState()); } else { const data = {}; diff --git a/src/services/authFlow/LoginState.js b/src/services/authFlow/LoginState.js index bbb5c5a..cb63349 100644 --- a/src/services/authFlow/LoginState.js +++ b/src/services/authFlow/LoginState.js @@ -13,6 +13,8 @@ export default class LoginState extends AbstractState { || /login|password/.test(context.getRequest().path) // TODO: improve me ) { context.navigate('/login'); + } else { + context.setState(new PasswordState()); } } diff --git a/tests/components/auth/reducer.test.js b/tests/components/auth/reducer.test.js index 66024a9..0cb40ab 100644 --- a/tests/components/auth/reducer.test.js +++ b/tests/components/auth/reducer.test.js @@ -1,9 +1,12 @@ import expect from 'unexpected'; import auth from 'components/auth/reducer'; -import { setLogin, SET_LOGIN } from 'components/auth/actions'; +import { + setLogin, SET_LOGIN, + setAccountSwitcher, SET_SWITCHER +} from 'components/auth/actions'; -describe('auth reducer', () => { +describe('components/auth/reducer', () => { describe(SET_LOGIN, () => { it('should set login', () => { const expectedLogin = 'foo'; @@ -13,4 +16,28 @@ describe('auth reducer', () => { }); }); }); + + 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 + }); + }); + + it('should disable switcher', () => { + const expectedValue = false; + + expect(auth(undefined, setAccountSwitcher(expectedValue)), 'to satisfy', { + isSwitcherEnabled: expectedValue + }); + }); + }); }); diff --git a/tests/services/authFlow/AuthFlow.test.js b/tests/services/authFlow/AuthFlow.test.js index 3c3fe82..7d00a2c 100644 --- a/tests/services/authFlow/AuthFlow.test.js +++ b/tests/services/authFlow/AuthFlow.test.js @@ -267,6 +267,7 @@ describe('AuthFlow', () => { '/password': LoginState, '/accept-rules': LoginState, '/oauth/permissions': LoginState, + '/oauth/choose-account': LoginState, '/oauth/finish': LoginState, '/oauth2/v1/foo': OAuthState, '/oauth2/v1': OAuthState, From 9e7d5b83389c8e8f56945363287033ef4e566817 Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Mon, 14 Nov 2016 07:28:25 +0200 Subject: [PATCH 18/34] #48: reset accounts state on logout --- src/components/accounts/actions.js | 10 ++++++++++ src/components/accounts/reducer.js | 5 ++++- src/components/user/actions.js | 3 +++ tests/components/accounts/actions.test.js | 12 +++++++++++- tests/components/accounts/reducer.test.js | 11 +++++++++-- tests/components/user/actions.test.js | 13 +++++++++++++ 6 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/components/accounts/actions.js b/src/components/accounts/actions.js index 7bc297c..59f7fa9 100644 --- a/src/components/accounts/actions.js +++ b/src/components/accounts/actions.js @@ -111,6 +111,16 @@ export function activate(account) { }; } +export const RESET = 'accounts:reset'; +/** + * @return {object} - action definition + */ +export function reset() { + return { + type: RESET + }; +} + export const UPDATE_TOKEN = 'accounts:updateToken'; /** * @param {string} token diff --git a/src/components/accounts/reducer.js b/src/components/accounts/reducer.js index 2b69268..36d6eb4 100644 --- a/src/components/accounts/reducer.js +++ b/src/components/accounts/reducer.js @@ -1,4 +1,4 @@ -import { ADD, REMOVE, ACTIVATE, UPDATE_TOKEN } from './actions'; +import { ADD, REMOVE, ACTIVATE, RESET, UPDATE_TOKEN } from './actions'; /** * @typedef {AccountsState} @@ -50,6 +50,9 @@ export default function accounts( active: payload }; + case RESET: + return accounts(undefined, {}); + case REMOVE: if (!payload || !payload.id) { throw new Error('Invalid or empty payload passed for accounts.remove'); diff --git a/src/components/user/actions.js b/src/components/user/actions.js index 713233f..a19f6b3 100644 --- a/src/components/user/actions.js +++ b/src/components/user/actions.js @@ -1,6 +1,7 @@ import { routeActions } from 'react-router-redux'; import accounts from 'services/api/accounts'; +import { reset as resetAccounts } from 'components/accounts/actions'; import authentication from 'services/api/authentication'; import { setLocale } from 'components/i18n/actions'; @@ -64,6 +65,8 @@ export function logout() { isGuest: true })); + dispatch(resetAccounts()); + dispatch(routeActions.push('/login')); resolve(); diff --git a/tests/components/accounts/actions.test.js b/tests/components/accounts/actions.test.js index fffab50..3c48e56 100644 --- a/tests/components/accounts/actions.test.js +++ b/tests/components/accounts/actions.test.js @@ -2,7 +2,14 @@ import expect from 'unexpected'; import accounts from 'services/api/accounts'; import authentication from 'services/api/authentication'; -import { authenticate, revoke, add, activate, remove, ADD, REMOVE, ACTIVATE } from 'components/accounts/actions'; +import { + authenticate, + revoke, + add, ADD, + activate, ACTIVATE, + remove, + reset +} from 'components/accounts/actions'; import { SET_LOCALE } from 'components/i18n/actions'; import { updateUser } from 'components/user/actions'; @@ -152,6 +159,9 @@ describe('components/accounts/actions', () => { {payload: {isGuest: true}} // updateUser({isGuest: true}) ]); + expect(dispatch, 'to have a call satisfying', [ + reset() + ]); // expect(dispatch, 'to have calls satisfying', [ // [remove(account)], // [expect.it('to be a function')] diff --git a/tests/components/accounts/reducer.test.js b/tests/components/accounts/reducer.test.js index 4729aa6..140766e 100644 --- a/tests/components/accounts/reducer.test.js +++ b/tests/components/accounts/reducer.test.js @@ -2,8 +2,8 @@ import expect from 'unexpected'; import accounts from 'components/accounts/reducer'; import { - updateToken, add, remove, activate, - ADD, REMOVE, ACTIVATE, UPDATE_TOKEN + updateToken, add, remove, activate, reset, + ADD, REMOVE, ACTIVATE, UPDATE_TOKEN, RESET } from 'components/accounts/actions'; const account = { @@ -94,6 +94,13 @@ describe('Accounts reducer', () => { }); }); + describe(RESET, () => { + it('should reset accounts state', () => + expect(accounts({...initial, available: [account]}, reset()), + 'to equal', initial) + ); + }); + describe(UPDATE_TOKEN, () => { it('should update token', () => { const newToken = 'newToken'; diff --git a/tests/components/user/actions.test.js b/tests/components/user/actions.test.js index 0b05cb0..450cefa 100644 --- a/tests/components/user/actions.test.js +++ b/tests/components/user/actions.test.js @@ -3,6 +3,7 @@ import expect from 'unexpected'; import { routeActions } from 'react-router-redux'; import request from 'services/request'; +import { reset, RESET } from 'components/accounts/actions'; import { logout, @@ -70,6 +71,7 @@ describe('components/user/actions', () => { }); testChangedToGuest(); + testAccountsReset(); testRedirectedToLogin(); }); @@ -89,6 +91,7 @@ describe('components/user/actions', () => { ); testChangedToGuest(); + testAccountsReset(); testRedirectedToLogin(); }); @@ -114,5 +117,15 @@ describe('components/user/actions', () => { }) ); } + + function testAccountsReset() { + it(`should dispatch ${RESET}`, () => + callThunk(logout).then(() => { + expect(dispatch, 'to have a call satisfying', [ + reset() + ]); + }) + ); + } }); }); From 5142d65b39fbd8713fa4b20aa13f1a4ce4496564 Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Tue, 15 Nov 2016 07:55:15 +0200 Subject: [PATCH 19/34] #48: call authentication.logout for each revoked account --- src/components/accounts/actions.js | 16 +- src/components/user/actions.js | 24 +-- src/services/api/authentication.js | 13 +- tests/components/accounts/actions.test.js | 154 +++++++++++++----- tests/components/user/actions.test.js | 17 +- .../refreshTokenMiddleware.test.js | 28 ++-- tests/services/api/authentication.test.js | 35 ++++ 7 files changed, 214 insertions(+), 73 deletions(-) diff --git a/src/components/accounts/actions.js b/src/components/accounts/actions.js index 59f7fa9..37fe539 100644 --- a/src/components/accounts/actions.js +++ b/src/components/accounts/actions.js @@ -59,7 +59,10 @@ export function revoke(account) { if (accountToReplace) { return dispatch(authenticate(accountToReplace)) - .then(() => dispatch(remove(account))); + .then(() => { + authentication.logout(account); + dispatch(remove(account)); + }); } return dispatch(logout()); @@ -111,8 +114,19 @@ export function activate(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() { diff --git a/src/components/user/actions.js b/src/components/user/actions.js index a19f6b3..4079147 100644 --- a/src/components/user/actions.js +++ b/src/components/user/actions.js @@ -1,7 +1,7 @@ import { routeActions } from 'react-router-redux'; import accounts from 'services/api/accounts'; -import { reset as resetAccounts } from 'components/accounts/actions'; +import { logoutAll } from 'components/accounts/actions'; import authentication from 'services/api/authentication'; import { setLocale } from 'components/i18n/actions'; @@ -54,24 +54,16 @@ export function setUser(payload) { export function logout() { return (dispatch, getState) => { - if (getState().user.token) { - authentication.logout(); - } + dispatch(setUser({ + lang: getState().user.lang, + isGuest: true + })); - return new Promise((resolve) => { - setTimeout(() => { // a tiny timeout to allow logout before user's token will be removed - dispatch(setUser({ - lang: getState().user.lang, - isGuest: true - })); + dispatch(logoutAll()); - dispatch(resetAccounts()); + dispatch(routeActions.push('/login')); - dispatch(routeActions.push('/login')); - - resolve(); - }, 0); - }); + return Promise.resolve(); }; } diff --git a/src/services/api/authentication.js b/src/services/api/authentication.js index 5a3e935..b495f5b 100644 --- a/src/services/api/authentication.js +++ b/src/services/api/authentication.js @@ -13,8 +13,17 @@ const authentication = { ); }, - logout() { - return request.post('/api/authentication/logout'); + /** + * @param {object} options + * @param {object} [options.token] - an optional token to overwrite headers + * in middleware and disable token auto-refresh + * + * @return {Promise} + */ + logout(options = {}) { + return request.post('/api/authentication/logout', {}, { + token: options.token + }); }, forgotPassword({ diff --git a/tests/components/accounts/actions.test.js b/tests/components/accounts/actions.test.js index 3c48e56..297dbdd 100644 --- a/tests/components/accounts/actions.test.js +++ b/tests/components/accounts/actions.test.js @@ -8,7 +8,8 @@ import { add, ADD, activate, ACTIVATE, remove, - reset + reset, + logoutAll } from 'components/accounts/actions'; import { SET_LOCALE } from 'components/i18n/actions'; @@ -116,58 +117,129 @@ describe('components/accounts/actions', () => { }); describe('#revoke()', () => { - it('should switch next account if available', () => { + beforeEach(() => { + sinon.stub(authentication, 'logout').named('authentication.logout'); + }); + + afterEach(() => { + authentication.logout.restore(); + }); + + describe('when one account available', () => { + beforeEach(() => { + getState.returns({ + accounts: { + active: account, + available: [account] + }, + 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 + ]) + ) + ); + + it('should update user state', () => + revoke(account)(dispatch, getState).then(() => + expect(dispatch, 'to have a call satisfying', [ + {payload: {isGuest: true}} + // updateUser({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, + 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 + ]) + ) + ); + }); + }); + + describe('#logoutAll()', () => { + const account2 = {...account, id: 2}; + + beforeEach(() => { getState.returns({ accounts: { active: account2, - available: [account] + available: [account, account2] }, user }); - return revoke(account2)(dispatch, getState).then(() => { - expect(dispatch, 'to have a call satisfying', [ - remove(account2) - ]); - expect(dispatch, 'to have a call satisfying', [ - activate(account) - ]); - expect(dispatch, 'to have a call satisfying', [ - updateUser({...user, isGuest: false}) - ]); - // expect(dispatch, 'to have calls satisfying', [ - // [remove(account2)], - // [expect.it('to be a function')] - // // [authenticate(account2)] // TODO: this is not a plain action. How should we simplify its testing? - // ]) - }); + sinon.stub(authentication, 'logout').named('authentication.logout'); }); - it('should logout if no other accounts available', () => { - getState.returns({ - accounts: { - active: account, - available: [] - }, - user - }); + afterEach(() => { + authentication.logout.restore(); + }); - revoke(account)(dispatch, getState).then(() => { - expect(dispatch, 'to have a call satisfying', [ - {payload: {isGuest: true}} - // updateUser({isGuest: true}) - ]); - expect(dispatch, 'to have a call satisfying', [ - reset() - ]); - // 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? - // ]) - }); + it('should call logout api method for each account', () => { + logoutAll()(dispatch, getState); + + expect(authentication.logout, 'to have calls satisfying', [ + [account], + [account2] + ]); + }); + + it('should dispatch reset', () => { + logoutAll()(dispatch, getState); + + expect(dispatch, 'to have a call satisfying', [ + reset() + ]); }); }); }); diff --git a/tests/components/user/actions.test.js b/tests/components/user/actions.test.js index 450cefa..693dce4 100644 --- a/tests/components/user/actions.test.js +++ b/tests/components/user/actions.test.js @@ -42,11 +42,16 @@ describe('components/user/actions', () => { }); describe('user with jwt', () => { + const token = 'iLoveRockNRoll'; + beforeEach(() => { getState.returns({ user: { - token: 'iLoveRockNRoll', lang: 'foo' + }, + accounts: { + active: {token}, + available: [{token}] } }); }); @@ -65,7 +70,7 @@ describe('components/user/actions', () => { return callThunk(logout).then(() => { expect(request.post, 'to have a call satisfying', [ - '/api/authentication/logout' + '/api/authentication/logout', {}, {} ]); }); }); @@ -75,11 +80,17 @@ describe('components/user/actions', () => { testRedirectedToLogin(); }); - describe('user without jwt', () => { // (a guest with partially filled user's state) + describe('user without jwt', () => { + // (a guest with partially filled user's state) + // DEPRECATED beforeEach(() => { getState.returns({ user: { lang: 'foo' + }, + accounts: { + active: null, + available: [] } }); }); diff --git a/tests/components/user/middlewares/refreshTokenMiddleware.test.js b/tests/components/user/middlewares/refreshTokenMiddleware.test.js index 6068872..9b9aa3d 100644 --- a/tests/components/user/middlewares/refreshTokenMiddleware.test.js +++ b/tests/components/user/middlewares/refreshTokenMiddleware.test.js @@ -17,6 +17,7 @@ describe('refreshTokenMiddleware', () => { beforeEach(() => { sinon.stub(authentication, 'requestToken').named('authentication.requestToken'); + sinon.stub(authentication, 'logout').named('authentication.logout'); getState = sinon.stub().named('store.getState'); dispatch = sinon.spy((arg) => @@ -28,6 +29,7 @@ describe('refreshTokenMiddleware', () => { afterEach(() => { authentication.requestToken.restore(); + authentication.logout.restore(); }); it('must be till 2100 to test with validToken', () => @@ -37,12 +39,14 @@ describe('refreshTokenMiddleware', () => { describe('#before', () => { describe('when token expired', () => { beforeEach(() => { + const account = { + token: expiredToken, + refreshToken + }; getState.returns({ accounts: { - active: { - token: expiredToken, - refreshToken - } + active: account, + available: [account] }, user: {} }); @@ -104,12 +108,14 @@ describe('refreshTokenMiddleware', () => { }); it('should if token can not be parsed', () => { + const account = { + token: 'realy bad token', + refreshToken + }; getState.returns({ accounts: { - active: { - token: 'realy bad token', - refreshToken - } + active: account, + available: [account] }, user: {} }); @@ -140,7 +146,8 @@ describe('refreshTokenMiddleware', () => { beforeEach(() => { getState.returns({ accounts: { - active: null + active: null, + available: [] }, user: { token: expiredToken, @@ -216,7 +223,8 @@ describe('refreshTokenMiddleware', () => { beforeEach(() => { getState.returns({ accounts: { - active: {refreshToken} + active: {refreshToken}, + available: [{refreshToken}] }, user: {} }); diff --git a/tests/services/api/authentication.test.js b/tests/services/api/authentication.test.js index 67025a8..6f054f9 100644 --- a/tests/services/api/authentication.test.js +++ b/tests/services/api/authentication.test.js @@ -1,5 +1,6 @@ import expect from 'unexpected'; +import request from 'services/request'; import authentication from 'services/api/authentication'; import accounts from 'services/api/accounts'; @@ -88,4 +89,38 @@ describe('authentication api', () => { }); }); }); + + describe('#logout', () => { + beforeEach(() => { + sinon.stub(request, 'post').named('request.post'); + }); + + afterEach(() => { + request.post.restore(); + }); + + it('should request logout api', () => { + authentication.logout(); + + expect(request.post, 'to have a call satisfying', [ + '/api/authentication/logout', {}, {} + ]); + }); + + it('returns a promise', () => { + request.post.returns(Promise.resolve()); + + return expect(authentication.logout(), 'to be fulfilled'); + }); + + it('overrides token if provided', () => { + const token = 'foo'; + + authentication.logout({token}); + + expect(request.post, 'to have a call satisfying', [ + '/api/authentication/logout', {}, {token} + ]); + }); + }); }); From 332c3d0ef7f3de5c63f776642823bf3c61282a37 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Thu, 17 Nov 2016 01:56:34 +0300 Subject: [PATCH 20/34] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=B1=D0=BE=D1=80=D0=B0=20?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=B9=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B2=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- scripts/i18n-collect.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index d586638..e777d2c 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": "./node_modules/.bin/babel-node ./scripts/i18n-collect.js", "build": "rm -rf dist/ && webpack --progress --colors -p" }, "dependencies": { 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'); From 1449d1ac794d4466327cd5ba7ff2d3bb0ccce440 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Thu, 17 Nov 2016 02:03:05 +0300 Subject: [PATCH 21/34] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D1=8B=20=D0=BD=D0=B0=20=D1=80=D1=83=D1=81=D1=81=D0=BA=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=B8=20=D0=B1=D0=B5=D0=BB=D0=BE=D1=80=D1=83=D1=81?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/i18n/be.json | 7 ++++++- src/i18n/en.json | 7 ++++++- src/i18n/ru.json | 7 ++++++- src/i18n/uk.json | 7 ++++++- 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/i18n/be.json b/src/i18n/be.json index 461a64e..01d6d67 100644 --- a/src/i18n/be.json +++ b/src/i18n/be.json @@ -1,4 +1,7 @@ { + "components.accounts.addAccount": "Дадаць акаўнт", + "components.accounts.goToEly": "Перайсці ў профіль Ely.by", + "components.accounts.logout": "Выйсці", "components.auth.acceptRules.accept": "Прыняць", "components.auth.acceptRules.declineAndLogout": "Адмовіцца і выйсці", "components.auth.acceptRules.description1": "Мы аднавілі {link}.", @@ -15,6 +18,9 @@ "components.auth.appInfo.documentation": "дакументацыю", "components.auth.appInfo.goToAuth": "Да аўтарызацыі", "components.auth.appInfo.useItYourself": "Наведайце нашу {link}, каб даведацца, як выкарыстоўваць гэты сэрвіс ў сваіх праектах.", + "components.auth.chooseAccount.addAccount": "Увайсці ў другі акаўнт", + "components.auth.chooseAccount.chooseAccountTitle": "Выбар акаўнта", + "components.auth.chooseAccount.description": "Вы выканалі ўваход у некалькі акаўнтаў. Пазначце, які вы жадаеце выкарыстаць для аўтарызацыі {appName}", "components.auth.finish.authForAppFailed": "Аўтарызацыя для {appName} не атрымалася", "components.auth.finish.authForAppSuccessful": "Аўтарызацыя для {appName} паспяхова выканана", "components.auth.finish.copy": "Скапіяваць", @@ -126,7 +132,6 @@ "components.profile.projectRules": "правілах праекта", "components.profile.twoFactorAuth": "Двухфактарная аўтэнтыфікацыя", "components.userbar.login": "Уваход", - "components.userbar.logout": "Выхад", "components.userbar.register": "Рэгістрацыя", "pages.root.siteName": "Ёly.by", "pages.rules.elyAccountsAsService": "{name} як сэрвіс", diff --git a/src/i18n/en.json b/src/i18n/en.json index c38c0dc..7ecf321 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1,4 +1,7 @@ { + "components.accounts.addAccount": "Add account", + "components.accounts.goToEly": "Go to Ely.by profile", + "components.accounts.logout": "Log out", "components.auth.acceptRules.accept": "Accept", "components.auth.acceptRules.declineAndLogout": "Decline and logout", "components.auth.acceptRules.description1": "We have updated our {link}.", @@ -15,6 +18,9 @@ "components.auth.appInfo.documentation": "documentation", "components.auth.appInfo.goToAuth": "Go to auth", "components.auth.appInfo.useItYourself": "Visit our {link}, to learn how to use this service in you projects.", + "components.auth.chooseAccount.addAccount": "Log into another account", + "components.auth.chooseAccount.chooseAccountTitle": "Choose an account", + "components.auth.chooseAccount.description": "You have logged in into multiple accounts. Please choose the one, you want to use to authorize {appName}", "components.auth.finish.authForAppFailed": "Authorization for {appName} was failed", "components.auth.finish.authForAppSuccessful": "Authorization for {appName} was successfully completed", "components.auth.finish.copy": "Copy", @@ -126,7 +132,6 @@ "components.profile.projectRules": "project rules", "components.profile.twoFactorAuth": "Two factor auth", "components.userbar.login": "Sign in", - "components.userbar.logout": "Logout", "components.userbar.register": "Join", "pages.root.siteName": "Ely.by", "pages.rules.elyAccountsAsService": "{name} as service", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 1007783..05cc076 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -1,4 +1,7 @@ { + "components.accounts.addAccount": "Добавить акккаунт", + "components.accounts.goToEly": "Перейти в профиль Ely.by", + "components.accounts.logout": "Выйти", "components.auth.acceptRules.accept": "Принять", "components.auth.acceptRules.declineAndLogout": "Отказаться и выйти", "components.auth.acceptRules.description1": "Мы обновили {link}.", @@ -15,6 +18,9 @@ "components.auth.appInfo.documentation": "документацию", "components.auth.appInfo.goToAuth": "К авторизации", "components.auth.appInfo.useItYourself": "Посетите нашу {link}, чтобы узнать, как использовать этот сервис в своих проектах.", + "components.auth.chooseAccount.addAccount": "Войти в другой аккаунт", + "components.auth.chooseAccount.chooseAccountTitle": "Выбор аккаунта", + "components.auth.chooseAccount.description": "Вы выполнили вход в несколько аккаунтов. Укажите, какой вы хотите использовать для авторизации {appName}", "components.auth.finish.authForAppFailed": "Авторизация для {appName} не удалась", "components.auth.finish.authForAppSuccessful": "Авторизация для {appName} успешно выполнена", "components.auth.finish.copy": "Скопировать", @@ -126,7 +132,6 @@ "components.profile.projectRules": "правилами проекта", "components.profile.twoFactorAuth": "Двухфакторная аутентификация", "components.userbar.login": "Вход", - "components.userbar.logout": "Выход", "components.userbar.register": "Регистрация", "pages.root.siteName": "Ely.by", "pages.rules.elyAccountsAsService": "{name} как сервис", diff --git a/src/i18n/uk.json b/src/i18n/uk.json index bcfe58e..eed8762 100644 --- a/src/i18n/uk.json +++ b/src/i18n/uk.json @@ -1,4 +1,7 @@ { + "components.accounts.addAccount": "Add account", + "components.accounts.goToEly": "Go to Ely.by profile", + "components.accounts.logout": "Log out", "components.auth.acceptRules.accept": "Прийняти", "components.auth.acceptRules.declineAndLogout": "Відмовитись і вийти", "components.auth.acceptRules.description1": "Ми оновили наші {link}.", @@ -15,6 +18,9 @@ "components.auth.appInfo.documentation": "документацію", "components.auth.appInfo.goToAuth": "До авторизації", "components.auth.appInfo.useItYourself": "Відвідайте нашу {link}, щоб дізнатися, як використовувати цей сервіс в своїх проектах.", + "components.auth.chooseAccount.addAccount": "Log into another account", + "components.auth.chooseAccount.chooseAccountTitle": "Choose an account", + "components.auth.chooseAccount.description": "You have logged in into multiple accounts. Please choose the one, you want to use to authorize {appName}", "components.auth.finish.authForAppFailed": "Авторизація для {appName} не вдалася", "components.auth.finish.authForAppSuccessful": "Авторизація для {appName} успішно виконана", "components.auth.finish.copy": "Скопіювати", @@ -126,7 +132,6 @@ "components.profile.projectRules": "правилами проекта", "components.profile.twoFactorAuth": "Двофакторна аутентифікація", "components.userbar.login": "Вхід", - "components.userbar.logout": "Вихід", "components.userbar.register": "Реєстрація", "pages.root.siteName": "Ely.by", "pages.rules.elyAccountsAsService": "{name} як сервіс", From 78132e9adb5055cdad7043fbad031b63c262e679 Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 19 Nov 2016 11:34:19 +0200 Subject: [PATCH 22/34] #48: add loader during account switching --- src/components/accounts/AccountSwitcher.jsx | 6 +++++- src/index.js | 3 ++- src/services/loader.js | 9 +++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 src/services/loader.js diff --git a/src/components/accounts/AccountSwitcher.jsx b/src/components/accounts/AccountSwitcher.jsx index 856a939..03d7b5f 100644 --- a/src/components/accounts/AccountSwitcher.jsx +++ b/src/components/accounts/AccountSwitcher.jsx @@ -4,6 +4,7 @@ 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'; @@ -137,9 +138,12 @@ export class AccountSwitcher extends Component { onSwitch = (account) => (event) => { event.preventDefault(); + loader.show(); + this.props.switchAccount(account) .then(() => this.props.onAfterAction()) - .then(() => this.props.onSwitch(account)); + .then(() => this.props.onSwitch(account)) + .finally(() => loader.hide()); }; onRemove = (account) => (event) => { diff --git a/src/index.js b/src/index.js index a5d083b..2c7c58e 100644 --- a/src/index.js +++ b/src/index.js @@ -13,6 +13,7 @@ import { IntlProvider } from 'components/i18n'; import routesFactory from 'routes'; import storeFactory from 'storeFactory'; import bsodFactory from 'components/ui/bsod/factory'; +import loader from 'services/loader'; const store = storeFactory(); @@ -52,7 +53,7 @@ Promise.all([ function stopLoading() { - document.getElementById('loader').classList.remove('is-active'); + loader.hide(); } import scrollTo from 'components/ui/scrollTo'; diff --git a/src/services/loader.js b/src/services/loader.js new file mode 100644 index 0000000..c1d8619 --- /dev/null +++ b/src/services/loader.js @@ -0,0 +1,9 @@ +export default { + show() { + document.getElementById('loader').classList.add('is-active'); + }, + + hide() { + document.getElementById('loader').classList.remove('is-active'); + } +}; From 9adf91a967fc60201bca65cb8d6dca0498a8c7a9 Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 19 Nov 2016 11:51:13 +0200 Subject: [PATCH 23/34] #48: increase token exp safety factor from 1 to 5 min --- src/components/user/middlewares/refreshTokenMiddleware.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/user/middlewares/refreshTokenMiddleware.js b/src/components/user/middlewares/refreshTokenMiddleware.js index a0eb3c8..491f88a 100644 --- a/src/components/user/middlewares/refreshTokenMiddleware.js +++ b/src/components/user/middlewares/refreshTokenMiddleware.js @@ -34,7 +34,7 @@ export default function refreshTokenMiddleware({dispatch, getState}) { } try { - const SAFETY_FACTOR = 60; // ask new token earlier to overcome time dissynchronization problem + const SAFETY_FACTOR = 300; // ask new token earlier to overcome time dissynchronization problem const jwt = getJWTPayload(token); if (jwt.exp - SAFETY_FACTOR < Date.now() / 1000) { From fcaef1aa613235937dda508f10ceea57e0153541 Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 19 Nov 2016 12:06:37 +0200 Subject: [PATCH 24/34] #48: ukrainian translation --- src/i18n/uk.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/i18n/uk.json b/src/i18n/uk.json index eed8762..046e080 100644 --- a/src/i18n/uk.json +++ b/src/i18n/uk.json @@ -1,7 +1,7 @@ { - "components.accounts.addAccount": "Add account", - "components.accounts.goToEly": "Go to Ely.by profile", - "components.accounts.logout": "Log out", + "components.accounts.addAccount": "Додати акаунт", + "components.accounts.goToEly": "Профіль на Ely.by", + "components.accounts.logout": "Вихід", "components.auth.acceptRules.accept": "Прийняти", "components.auth.acceptRules.declineAndLogout": "Відмовитись і вийти", "components.auth.acceptRules.description1": "Ми оновили наші {link}.", @@ -18,9 +18,9 @@ "components.auth.appInfo.documentation": "документацію", "components.auth.appInfo.goToAuth": "До авторизації", "components.auth.appInfo.useItYourself": "Відвідайте нашу {link}, щоб дізнатися, як використовувати цей сервіс в своїх проектах.", - "components.auth.chooseAccount.addAccount": "Log into another account", - "components.auth.chooseAccount.chooseAccountTitle": "Choose an account", - "components.auth.chooseAccount.description": "You have logged in into multiple accounts. Please choose the one, you want to use to authorize {appName}", + "components.auth.chooseAccount.addAccount": "Увійти в інший акаунт", + "components.auth.chooseAccount.chooseAccountTitle": "Оберіть акаунт", + "components.auth.chooseAccount.description": "Ви увійшли у декілька акаунтів. Будь ласка, оберіть акаунт, який ви бажаєте використовувати для авторизації {appName}", "components.auth.finish.authForAppFailed": "Авторизація для {appName} не вдалася", "components.auth.finish.authForAppSuccessful": "Авторизація для {appName} успішно виконана", "components.auth.finish.copy": "Скопіювати", From 0360c6ec0c047e5cdee60e928f7e0910027525ab Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 19 Nov 2016 12:19:15 +0200 Subject: [PATCH 25/34] #48: fix choose account dropdown not updated on language change --- src/components/accounts/AccountSwitcher.jsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/accounts/AccountSwitcher.jsx b/src/components/accounts/AccountSwitcher.jsx index 03d7b5f..79829f0 100644 --- a/src/components/accounts/AccountSwitcher.jsx +++ b/src/components/accounts/AccountSwitcher.jsx @@ -158,8 +158,9 @@ export class AccountSwitcher extends Component { import { connect } from 'react-redux'; import { authenticate, revoke } from 'components/accounts/actions'; -export default connect(({accounts}) => ({ - accounts +export default connect(({accounts, user}) => ({ + accounts, + userLang: user.lang // this is to force re-render on lang change }), { switchAccount: authenticate, removeAccount: revoke From d65c09f644dd31412536e01fb52c5a145425faed Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 19 Nov 2016 12:38:00 +0200 Subject: [PATCH 26/34] #48: smooth loader appereance animation --- src/components/ui/loader/loader.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/ui/loader/loader.scss b/src/components/ui/loader/loader.scss index 65cf05b..6d33281 100644 --- a/src/components/ui/loader/loader.scss +++ b/src/components/ui/loader/loader.scss @@ -15,7 +15,6 @@ &.is-active { opacity: 1; visibility: visible; - transition: 0.05s ease; } } From 42c4d3fe58d82d176fd6d057bd967eb45bec5018 Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 19 Nov 2016 12:38:53 +0200 Subject: [PATCH 27/34] #48: uk translate for 6th rule about nickname --- src/i18n/uk.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/i18n/uk.json b/src/i18n/uk.json index 046e080..0a3b7fd 100644 --- a/src/i18n/uk.json +++ b/src/i18n/uk.json @@ -145,6 +145,7 @@ "pages.rules.emailAndNickname3": "На призначений для користувача нікнейм, який використовується у грі, не накладаються будь-які моральні обмеження.", "pages.rules.emailAndNickname4": "Ніки, що належать відомим особистостям, за вимогою, можуть бути звільнені у їх користь після встановленню цієї самої особистості.", "pages.rules.emailAndNickname5": "Власник преміум-аккаунта Minecraft має право вимагати відновлення контролю над своїм ніком. У цьому випадку вам необхідно буде протягом 3-х днів змінити нік, або це буде зроблено автоматично.", + "pages.rules.emailAndNickname6": "Якщо на вашому акаунті не було активності протягом останніх 3 місяців, ваш нік може будти зайнятий іншим користовичем.", "pages.rules.emailAndNickname7": "Ми не несемо відповідальності за втрачений прогрес на ігрових серверах у результаті зміни ника, включаючи випадки зміни ника на вимогу з нашого боку.", "pages.rules.mainProvision1": "Сервіс {name} призначений для організації безпечного доступу до призначених для користувача аккаунтів проекту Ely.by, його партнерів і будь-яких сторонніх проектів, які бажають використовувати один з наших сервісів.", "pages.rules.mainProvision2": "Ми (тут і надалі) — команда розробників проекту Ely.by, що займаються створенням якісних сервісів для спільноти Minecraft.", From 13b4dcbbe8b02ebbc18d2ad58f4a5abad82c4bee Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 19 Nov 2016 12:54:24 +0200 Subject: [PATCH 28/34] #48: reset oauthData on logo click --- src/components/auth/actions.js | 7 +++++++ src/pages/root/RootPage.jsx | 8 ++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/components/auth/actions.js b/src/components/auth/actions.js index 5642904..d9823b3 100644 --- a/src/components/auth/actions.js +++ b/src/components/auth/actions.js @@ -231,6 +231,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 { diff --git a/src/pages/root/RootPage.jsx b/src/pages/root/RootPage.jsx index a335f5d..1d2f2a4 100644 --- a/src/pages/root/RootPage.jsx +++ b/src/pages/root/RootPage.jsx @@ -31,7 +31,7 @@ function RootPage(props) { })}>
- +
@@ -57,12 +57,16 @@ RootPage.propTypes = { pathname: PropTypes.string }).isRequired, children: PropTypes.element, + resetOAuth: PropTypes.func.isRequired, isPopupActive: PropTypes.bool.isRequired }; import { connect } from 'react-redux'; +import { resetOAuth } from 'components/auth/actions'; export default connect((state) => ({ user: state.user, isPopupActive: state.popup.popups.length > 0 -}))(RootPage); +}), { + resetOAuth +})(RootPage); From 79a9efb561c3e76bb046507a976e521e2092515c Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 19 Nov 2016 14:19:23 +0200 Subject: [PATCH 29/34] #48: add logout all link on choose account panel --- package.json | 2 +- src/components/auth/actions.js | 7 +++---- src/components/auth/chooseAccount/ChooseAccount.intl.json | 1 + src/components/auth/chooseAccount/ChooseAccount.jsx | 7 ++++++- src/i18n/be.json | 1 + src/i18n/en.json | 1 + src/i18n/ru.json | 1 + src/i18n/uk.json | 1 + src/services/authFlow/ChooseAccountState.js | 4 ++++ src/services/authFlow/index.js | 4 +--- 10 files changed, 20 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index e777d2c..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": "./node_modules/.bin/babel-node ./scripts/i18n-collect.js", + "i18n": "babel-node ./scripts/i18n-collect.js", "build": "rm -rf dist/ && webpack --progress --colors -p" }, "dependencies": { diff --git a/src/components/auth/actions.js b/src/components/auth/actions.js index d9823b3..54f8aa6 100644 --- a/src/components/auth/actions.js +++ b/src/components/auth/actions.js @@ -1,6 +1,6 @@ import { routeActions } from 'react-router-redux'; -import { updateUser, logout as logoutUser, acceptRules as userAcceptRules } 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'; @@ -24,6 +24,7 @@ export function login({login = '', password = '', rememberMe = false}) { } 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 return dispatch(logout()); } @@ -143,9 +144,7 @@ export function clearErrors() { return setErrors(null); } -export function logout() { - return logoutUser(); -} +export { logout, updateUser } from 'components/user/actions'; /** * @param {object} oauthData diff --git a/src/components/auth/chooseAccount/ChooseAccount.intl.json b/src/components/auth/chooseAccount/ChooseAccount.intl.json index c32f22c..9b207ca 100644 --- a/src/components/auth/chooseAccount/ChooseAccount.intl.json +++ b/src/components/auth/chooseAccount/ChooseAccount.intl.json @@ -1,5 +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 index b220f7d..0aaa6de 100644 --- a/src/components/auth/chooseAccount/ChooseAccount.jsx +++ b/src/components/auth/chooseAccount/ChooseAccount.jsx @@ -7,5 +7,10 @@ export default factory({ body: Body, footer: { label: messages.addAccount - } + }, + links: [ + { + label: messages.logoutAll + } + ] }); diff --git a/src/i18n/be.json b/src/i18n/be.json index 01d6d67..1175afd 100644 --- a/src/i18n/be.json +++ b/src/i18n/be.json @@ -21,6 +21,7 @@ "components.auth.chooseAccount.addAccount": "Увайсці ў другі акаўнт", "components.auth.chooseAccount.chooseAccountTitle": "Выбар акаўнта", "components.auth.chooseAccount.description": "Вы выканалі ўваход у некалькі акаўнтаў. Пазначце, які вы жадаеце выкарыстаць для аўтарызацыі {appName}", + "components.auth.chooseAccount.logoutAll": "Log out from all accounts", "components.auth.finish.authForAppFailed": "Аўтарызацыя для {appName} не атрымалася", "components.auth.finish.authForAppSuccessful": "Аўтарызацыя для {appName} паспяхова выканана", "components.auth.finish.copy": "Скапіяваць", diff --git a/src/i18n/en.json b/src/i18n/en.json index 7ecf321..ba29676 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -21,6 +21,7 @@ "components.auth.chooseAccount.addAccount": "Log into another account", "components.auth.chooseAccount.chooseAccountTitle": "Choose an account", "components.auth.chooseAccount.description": "You have logged in into multiple accounts. Please choose the one, you want to use to authorize {appName}", + "components.auth.chooseAccount.logoutAll": "Log out from all accounts", "components.auth.finish.authForAppFailed": "Authorization for {appName} was failed", "components.auth.finish.authForAppSuccessful": "Authorization for {appName} was successfully completed", "components.auth.finish.copy": "Copy", diff --git a/src/i18n/ru.json b/src/i18n/ru.json index 05cc076..2f7a722 100644 --- a/src/i18n/ru.json +++ b/src/i18n/ru.json @@ -21,6 +21,7 @@ "components.auth.chooseAccount.addAccount": "Войти в другой аккаунт", "components.auth.chooseAccount.chooseAccountTitle": "Выбор аккаунта", "components.auth.chooseAccount.description": "Вы выполнили вход в несколько аккаунтов. Укажите, какой вы хотите использовать для авторизации {appName}", + "components.auth.chooseAccount.logoutAll": "Выйти из всех аккаунтов", "components.auth.finish.authForAppFailed": "Авторизация для {appName} не удалась", "components.auth.finish.authForAppSuccessful": "Авторизация для {appName} успешно выполнена", "components.auth.finish.copy": "Скопировать", diff --git a/src/i18n/uk.json b/src/i18n/uk.json index 0a3b7fd..7225fc1 100644 --- a/src/i18n/uk.json +++ b/src/i18n/uk.json @@ -21,6 +21,7 @@ "components.auth.chooseAccount.addAccount": "Увійти в інший акаунт", "components.auth.chooseAccount.chooseAccountTitle": "Оберіть акаунт", "components.auth.chooseAccount.description": "Ви увійшли у декілька акаунтів. Будь ласка, оберіть акаунт, який ви бажаєте використовувати для авторизації {appName}", + "components.auth.chooseAccount.logoutAll": "Вийти з усіх аккаунтів", "components.auth.finish.authForAppFailed": "Авторизація для {appName} не вдалася", "components.auth.finish.authForAppSuccessful": "Авторизація для {appName} успішно виконана", "components.auth.finish.copy": "Скопіювати", diff --git a/src/services/authFlow/ChooseAccountState.js b/src/services/authFlow/ChooseAccountState.js index 069b3a6..1ef9708 100644 --- a/src/services/authFlow/ChooseAccountState.js +++ b/src/services/authFlow/ChooseAccountState.js @@ -17,4 +17,8 @@ export default class ChooseAccountState extends AbstractState { context.setState(new LoginState()); } } + + reject(context) { + context.run('logout'); + } } diff --git a/src/services/authFlow/index.js b/src/services/authFlow/index.js index c75fd4c..8a365e3 100644 --- a/src/services/authFlow/index.js +++ b/src/services/authFlow/index.js @@ -1,11 +1,9 @@ import AuthFlow from './AuthFlow'; import * as actions from 'components/auth/actions'; -import {updateUser} from 'components/user/actions'; const availableActions = { - ...actions, - updateUser + ...actions }; export default new AuthFlow(availableActions); From 97faabbc702308f95ab4c6ee0cf10ed121ed2298 Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 19 Nov 2016 14:28:45 +0200 Subject: [PATCH 30/34] #48: add ely.by link --- src/components/accounts/AccountSwitcher.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/accounts/AccountSwitcher.jsx b/src/components/accounts/AccountSwitcher.jsx index 79829f0..3ce1663 100644 --- a/src/components/accounts/AccountSwitcher.jsx +++ b/src/components/accounts/AccountSwitcher.jsx @@ -72,7 +72,7 @@ export class AccountSwitcher extends Component {
From fa070096c650ca6dad7689d27b1e4bca555b7c23 Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 19 Nov 2016 14:37:17 +0200 Subject: [PATCH 31/34] #48: recalculate choose account panel height, when user log out from one account --- src/components/auth/PanelTransition.jsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/components/auth/PanelTransition.jsx b/src/components/auth/PanelTransition.jsx index 8d09d73..3746720 100644 --- a/src/components/auth/PanelTransition.jsx +++ b/src/components/auth/PanelTransition.jsx @@ -89,6 +89,9 @@ class PanelTransition extends Component { login: PropTypes.string }), user: userShape, + accounts: PropTypes.shape({ + available: PropTypes.array + }), requestRedraw: PropTypes.func, clearErrors: PropTypes.func, resolve: PropTypes.func, @@ -308,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}) { @@ -457,6 +465,7 @@ export default connect((state) => { return { user, + accounts: state.accounts, // need this, to re-render height auth: state.auth, resolve: authFlow.resolve.bind(authFlow), reject: authFlow.reject.bind(authFlow) From d2102317669be9c82cba934b6b167318f82bc96c Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 19 Nov 2016 14:43:50 +0200 Subject: [PATCH 32/34] #48: do not choose account during oauth, when it is only one --- src/services/authFlow/CompleteState.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/authFlow/CompleteState.js b/src/services/authFlow/CompleteState.js index bb4bc7d..49b55bf 100644 --- a/src/services/authFlow/CompleteState.js +++ b/src/services/authFlow/CompleteState.js @@ -14,7 +14,7 @@ export default class CompleteState extends AbstractState { } enter(context) { - const {auth = {}, user} = context.getState(); + const {auth = {}, user, accounts} = context.getState(); if (user.isGuest) { context.setState(new LoginState()); @@ -23,7 +23,7 @@ export default class CompleteState extends AbstractState { } else if (user.shouldAcceptRules) { context.setState(new AcceptRulesState()); } else if (auth.oauth && auth.oauth.clientId) { - if (auth.isSwitcherEnabled) { + if (auth.isSwitcherEnabled && accounts.available.length > 1) { context.setState(new ChooseAccountState()); } else if (auth.oauth.code) { context.setState(new FinishState()); From 2beab5b6bc1b5abc2e8ea6ea36f190914507ec10 Mon Sep 17 00:00:00 2001 From: ErickSkrauch Date: Sat, 19 Nov 2016 16:25:25 +0300 Subject: [PATCH 33/34] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B2=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=B1=D0=B5=D0=BB=D0=BE=D1=80=D1=83=D1=81=D1=81?= =?UTF-8?q?=D0=BA=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/i18n/be.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/be.json b/src/i18n/be.json index 1175afd..06c61c7 100644 --- a/src/i18n/be.json +++ b/src/i18n/be.json @@ -21,7 +21,7 @@ "components.auth.chooseAccount.addAccount": "Увайсці ў другі акаўнт", "components.auth.chooseAccount.chooseAccountTitle": "Выбар акаўнта", "components.auth.chooseAccount.description": "Вы выканалі ўваход у некалькі акаўнтаў. Пазначце, які вы жадаеце выкарыстаць для аўтарызацыі {appName}", - "components.auth.chooseAccount.logoutAll": "Log out from all accounts", + "components.auth.chooseAccount.logoutAll": "Выйсці з усіх акаўтаў", "components.auth.finish.authForAppFailed": "Аўтарызацыя для {appName} не атрымалася", "components.auth.finish.authForAppSuccessful": "Аўтарызацыя для {appName} паспяхова выканана", "components.auth.finish.copy": "Скапіяваць", From 6498858d33489a6eee44f18c23cd3394ca3e75fd Mon Sep 17 00:00:00 2001 From: SleepWalker Date: Sat, 19 Nov 2016 16:41:15 +0200 Subject: [PATCH 34/34] #48: add support for prompt and login_hint oauth params --- src/components/auth/PanelTransition.jsx | 24 +++++--- src/components/auth/actions.js | 21 ++++++- src/components/auth/reducer.js | 2 + src/index.js | 5 +- src/services/api/oauth.js | 2 + src/services/authFlow/AuthFlow.js | 4 +- src/services/authFlow/ChooseAccountState.js | 1 + src/services/authFlow/CompleteState.js | 33 ++++++++++- src/services/authFlow/OAuthState.js | 2 + tests/components/auth/actions.test.js | 8 ++- .../authFlow/AuthFlow.functional.test.js | 3 +- .../authFlow/ChooseAccountState.test.js | 56 +++++++++++++++++++ tests/services/authFlow/CompleteState.test.js | 44 +++++++++------ tests/services/authFlow/OAuthState.test.js | 4 ++ 14 files changed, 173 insertions(+), 36 deletions(-) create mode 100644 tests/services/authFlow/ChooseAccountState.test.js diff --git a/src/components/auth/PanelTransition.jsx b/src/components/auth/PanelTransition.jsx index 3746720..f493455 100644 --- a/src/components/auth/PanelTransition.jsx +++ b/src/components/auth/PanelTransition.jsx @@ -450,17 +450,23 @@ class PanelTransition extends Component { export default connect((state) => { const {login} = state.auth; - const user = { - ...state.user, - isGuest: true, - email: '', - username: '' + let user = { + ...state.user }; - if (/[@.]/.test(login)) { - user.email = login; - } else { - user.username = login; + if (login) { + user = { + ...user, + isGuest: true, + email: '', + username: '' + }; + + if (/[@.]/.test(login)) { + user.email = login; + } else { + user.username = login; + } } return { diff --git a/src/components/auth/actions.js b/src/components/auth/actions.js index 54f8aa6..fb91af0 100644 --- a/src/components/auth/actions.js +++ b/src/components/auth/actions.js @@ -145,6 +145,7 @@ export function clearErrors() { } export { logout, updateUser } from 'components/user/actions'; +export { authenticate } from 'components/accounts/actions'; /** * @param {object} oauthData @@ -153,6 +154,13 @@ export { logout, updateUser } from 'components/user/actions'; * @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} @@ -163,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(), @@ -246,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 } }; diff --git a/src/components/auth/reducer.js b/src/components/auth/reducer.js index 4d9cee3..28d470a 100644 --- a/src/components/auth/reducer.js +++ b/src/components/auth/reducer.js @@ -114,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/index.js b/src/index.js index 2c7c58e..8c5d79b 100644 --- a/src/index.js +++ b/src/index.js @@ -90,7 +90,10 @@ function restoreScroll() { /* global process: false */ if (process.env.NODE_ENV !== 'production') { // some shortcuts for testing on localhost - window.testOAuth = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email'; + window.testOAuth = (loginHint = '') => location.href = `/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&login_hint=${loginHint}`; + window.testOAuthPromptAccount = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&prompt=select_account'; + window.testOAuthPromptPermissions = (loginHint = '') => location.href = `/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&prompt=consent&login_hint=${loginHint}`; + window.testOAuthPromptAll = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=http%3A%2F%2Fely.by%2Fauthorization%2Foauth&response_type=code&scope=account_info%2Caccount_email&prompt=select_account,consent'; window.testOAuthStatic = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=static_page_with_code&response_type=code&scope=account_info%2Caccount_email'; window.testOAuthStaticCode = () => location.href = '/oauth2/v1/ely?client_id=ely&redirect_uri=static_page&response_type=code&scope=account_info%2Caccount_email'; diff --git a/src/services/api/oauth.js b/src/services/api/oauth.js index bb2194e..f374f00 100644 --- a/src/services/api/oauth.js +++ b/src/services/api/oauth.js @@ -57,6 +57,8 @@ function getOAuthRequest(oauthData) { response_type: oauthData.responseType, description: oauthData.description, scope: oauthData.scope, + prompt: oauthData.prompt, + login_hint: oauthData.loginHint, state: oauthData.state }; } diff --git a/src/services/authFlow/AuthFlow.js b/src/services/authFlow/AuthFlow.js index 32e22e0..f205df4 100644 --- a/src/services/authFlow/AuthFlow.js +++ b/src/services/authFlow/AuthFlow.js @@ -192,8 +192,8 @@ export default class AuthFlow { * @return {bool} - whether oauth state is being restored */ restoreOAuthState() { - if (this.getRequest().path.indexOf('/register') === 0) { - // allow register + if (/^\/(register|oauth2)/.test(this.getRequest().path)) { + // allow register or the new oauth requests return; } diff --git a/src/services/authFlow/ChooseAccountState.js b/src/services/authFlow/ChooseAccountState.js index 1ef9708..ffa57b6 100644 --- a/src/services/authFlow/ChooseAccountState.js +++ b/src/services/authFlow/ChooseAccountState.js @@ -8,6 +8,7 @@ export default class ChooseAccountState extends AbstractState { } resolve(context, payload) { + // do not ask again after user adds account, or chooses an existed one context.run('setAccountSwitcher', false); if (payload.id) { diff --git a/src/services/authFlow/CompleteState.js b/src/services/authFlow/CompleteState.js index 49b55bf..3e0aae0 100644 --- a/src/services/authFlow/CompleteState.js +++ b/src/services/authFlow/CompleteState.js @@ -6,6 +6,9 @@ import ActivationState from './ActivationState'; import AcceptRulesState from './AcceptRulesState'; import FinishState from './FinishState'; +const PROMPT_ACCOUNT_CHOOSE = 'select_account'; +const PROMPT_PERMISSIONS = 'consent'; + export default class CompleteState extends AbstractState { constructor(options = {}) { super(options); @@ -23,7 +26,33 @@ export default class CompleteState extends AbstractState { } else if (user.shouldAcceptRules) { context.setState(new AcceptRulesState()); } else if (auth.oauth && auth.oauth.clientId) { - if (auth.isSwitcherEnabled && accounts.available.length > 1) { + let isSwitcherEnabled = auth.isSwitcherEnabled; + + if (auth.oauth.loginHint) { + const account = accounts.available.filter((account) => + account.id === auth.oauth.loginHint * 1 + || account.email === auth.oauth.loginHint + || account.username === auth.oauth.loginHint + )[0]; + + if (account) { + // disable switching, because we are know the account, user must be authorized with + context.run('setAccountSwitcher', false); + isSwitcherEnabled = false; + + if (account.id !== accounts.active.id) { + // lets switch user to an account, that is needed for auth + return context.run('authenticate', account) + .then(() => context.setState(new CompleteState())); + } + } + } + + if (isSwitcherEnabled + && (accounts.available.length > 1 + || auth.oauth.prompt.includes(PROMPT_ACCOUNT_CHOOSE) + ) + ) { context.setState(new ChooseAccountState()); } else if (auth.oauth.code) { context.setState(new FinishState()); @@ -31,7 +60,7 @@ export default class CompleteState extends AbstractState { const data = {}; if (typeof this.isPermissionsAccepted !== 'undefined') { data.accept = this.isPermissionsAccepted; - } else if (auth.oauth.acceptRequired) { + } else if (auth.oauth.acceptRequired || auth.oauth.prompt.includes(PROMPT_PERMISSIONS)) { context.setState(new PermissionsState()); return; } diff --git a/src/services/authFlow/OAuthState.js b/src/services/authFlow/OAuthState.js index 90ef6cc..e556cbc 100644 --- a/src/services/authFlow/OAuthState.js +++ b/src/services/authFlow/OAuthState.js @@ -11,6 +11,8 @@ export default class OAuthState extends AbstractState { responseType: query.response_type, description: query.description, scope: query.scope, + prompt: query.prompt, + loginHint: query.login_hint, state: query.state }).then(() => context.setState(new CompleteState())); } diff --git a/tests/components/auth/actions.test.js b/tests/components/auth/actions.test.js index 8afd00b..4e005b4 100644 --- a/tests/components/auth/actions.test.js +++ b/tests/components/auth/actions.test.js @@ -79,7 +79,11 @@ describe('components/auth/actions', () => { callThunk(oAuthValidate, oauthData).then(() => { expectDispatchCalls([ [setClient(resp.client)], - [setOAuthRequest(resp.oAuth)], + [setOAuthRequest({ + ...resp.oAuth, + prompt: 'none', + loginHint: undefined + })], [setScopes(resp.session.scopes)] ]); }) @@ -102,7 +106,7 @@ describe('components/auth/actions', () => { return callThunk(oAuthComplete).then(() => { expect(request.post, 'to have a call satisfying', [ - '/api/oauth2/v1/complete?client_id=&redirect_uri=&response_type=&description=&scope=&state=', + '/api/oauth2/v1/complete?client_id=&redirect_uri=&response_type=&description=&scope=&prompt=&login_hint=&state=', {} ]); }); diff --git a/tests/services/authFlow/AuthFlow.functional.test.js b/tests/services/authFlow/AuthFlow.functional.test.js index fa281dc..451c665 100644 --- a/tests/services/authFlow/AuthFlow.functional.test.js +++ b/tests/services/authFlow/AuthFlow.functional.test.js @@ -84,7 +84,8 @@ describe('AuthFlow.functional', () => { auth: { oauth: { - clientId: 123 + clientId: 123, + prompt: [] } } }); diff --git a/tests/services/authFlow/ChooseAccountState.test.js b/tests/services/authFlow/ChooseAccountState.test.js new file mode 100644 index 0000000..58f2021 --- /dev/null +++ b/tests/services/authFlow/ChooseAccountState.test.js @@ -0,0 +1,56 @@ +import ChooseAccountState from 'services/authFlow/ChooseAccountState'; +import CompleteState from 'services/authFlow/CompleteState'; +import LoginState from 'services/authFlow/LoginState'; + +import { bootstrap, expectState, expectNavigate, expectRun } from './helpers'; + +describe('ChooseAccountState', () => { + let state; + let context; + let mock; + + beforeEach(() => { + state = new ChooseAccountState(); + + const data = bootstrap(); + context = data.context; + mock = data.mock; + }); + + afterEach(() => { + mock.verify(); + }); + + describe('#enter', () => { + it('should navigate to /oauth/choose-account', () => { + expectNavigate(mock, '/oauth/choose-account'); + + state.enter(context); + }); + }); + + describe('#resolve', () => { + it('should transition to complete if existed account was choosen', () => { + expectRun(mock, 'setAccountSwitcher', false); + expectState(mock, CompleteState); + + state.resolve(context, {id: 123}); + }); + + it('should transition to login if user wants to add new account', () => { + expectRun(mock, 'setAccountSwitcher', false); + expectNavigate(mock, '/login'); + expectState(mock, LoginState); + + state.resolve(context, {}); + }); + }); + + describe('#reject', () => { + it('should logout', () => { + expectRun(mock, 'logout'); + + state.reject(context); + }); + }); +}); diff --git a/tests/services/authFlow/CompleteState.test.js b/tests/services/authFlow/CompleteState.test.js index b68b834..b8149e5 100644 --- a/tests/services/authFlow/CompleteState.test.js +++ b/tests/services/authFlow/CompleteState.test.js @@ -144,7 +144,8 @@ describe('CompleteState', () => { }, auth: { oauth: { - clientId: 'ely.by' + clientId: 'ely.by', + prompt: [] } } }); @@ -166,7 +167,8 @@ describe('CompleteState', () => { }, auth: { oauth: { - clientId: 'ely.by' + clientId: 'ely.by', + prompt: [] } } }); @@ -194,7 +196,8 @@ describe('CompleteState', () => { }, auth: { oauth: { - clientId: 'ely.by' + clientId: 'ely.by', + prompt: [] } } }); @@ -225,7 +228,8 @@ describe('CompleteState', () => { }, auth: { oauth: { - clientId: 'ely.by' + clientId: 'ely.by', + prompt: [] } } }); @@ -242,21 +246,21 @@ describe('CompleteState', () => { return promise.catch(mock.verify.bind(mock)); }; - it('should transition to finish state if rejected with static_page', () => { - return testOAuth('resolve', {redirectUri: 'static_page'}, FinishState); - }); + it('should transition to finish state if rejected with static_page', () => + testOAuth('resolve', {redirectUri: 'static_page'}, FinishState) + ); - it('should transition to finish state if rejected with static_page_with_code', () => { - return testOAuth('resolve', {redirectUri: 'static_page_with_code'}, FinishState); - }); + it('should transition to finish state if rejected with static_page_with_code', () => + testOAuth('resolve', {redirectUri: 'static_page_with_code'}, FinishState) + ); - it('should transition to login state if rejected with unauthorized', () => { - return testOAuth('reject', {unauthorized: true}, LoginState); - }); + it('should transition to login state if rejected with unauthorized', () => + testOAuth('reject', {unauthorized: true}, LoginState) + ); - it('should transition to permissions state if rejected with acceptRequired', () => { - return testOAuth('reject', {acceptRequired: true}, PermissionsState); - }); + it('should transition to permissions state if rejected with acceptRequired', () => + testOAuth('reject', {acceptRequired: true}, PermissionsState) + ); }); describe('permissions accept', () => { @@ -285,7 +289,8 @@ describe('CompleteState', () => { }, auth: { oauth: { - clientId: 'ely.by' + clientId: 'ely.by', + prompt: [] } } }); @@ -309,7 +314,8 @@ describe('CompleteState', () => { }, auth: { oauth: { - clientId: 'ely.by' + clientId: 'ely.by', + prompt: [] } } }); @@ -337,6 +343,7 @@ describe('CompleteState', () => { auth: { oauth: { clientId: 'ely.by', + prompt: [], acceptRequired: true } } @@ -365,6 +372,7 @@ describe('CompleteState', () => { auth: { oauth: { clientId: 'ely.by', + prompt: [], acceptRequired: true } } diff --git a/tests/services/authFlow/OAuthState.test.js b/tests/services/authFlow/OAuthState.test.js index f418b55..25f660f 100644 --- a/tests/services/authFlow/OAuthState.test.js +++ b/tests/services/authFlow/OAuthState.test.js @@ -28,6 +28,8 @@ describe('OAuthState', () => { response_type: 'response_type', description: 'description', scope: 'scope', + prompt: 'none', + login_hint: 1, state: 'state' }; @@ -42,6 +44,8 @@ describe('OAuthState', () => { responseType: query.response_type, description: query.description, scope: query.scope, + prompt: query.prompt, + loginHint: query.login_hint, state: query.state }) ).returns({then() {}});