Create app namespace for all absolute requires of app modules. Move all packages under packages yarn workspace

This commit is contained in:
SleepWalker
2019-12-07 21:02:00 +02:00
parent d8d2df0702
commit f9d3bb4e20
404 changed files with 758 additions and 742 deletions

View File

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

View File

@@ -0,0 +1,198 @@
import React from 'react';
import { connect } from 'react-redux';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { FormattedMessage as Message } from 'react-intl';
import loader from 'app/services/loader';
import { SKIN_DARK, COLOR_WHITE, Skin } from 'app/components/ui';
import { Button } from 'app/components/ui/form';
import { authenticate, revoke } from 'app/components/accounts/actions';
import { getActiveAccount, Account } from 'app/components/accounts/reducer';
import { RootState } from 'app/reducers';
import styles from './accountSwitcher.scss';
import messages from './AccountSwitcher.intl.json';
interface Props {
switchAccount: (account: Account) => Promise<Account>;
removeAccount: (account: Account) => Promise<void>;
// called after each action performed
onAfterAction: () => void;
// called after switching an account. The active account will be passed as arg
onSwitch: (account: Account) => void;
accounts: RootState['accounts'];
skin: Skin;
// whether active account should be expanded and shown on the top
highlightActiveAccount: boolean;
// whether to show logout icon near each account
allowLogout: boolean;
// whether to show add account button
allowAdd: boolean;
}
export class AccountSwitcher extends React.Component<Props> {
static defaultProps: Partial<Props> = {
skin: SKIN_DARK,
highlightActiveAccount: true,
allowLogout: true,
allowAdd: true,
onAfterAction() {},
onSwitch() {},
};
render() {
const {
accounts,
skin,
allowAdd,
allowLogout,
highlightActiveAccount,
} = this.props;
const activeAccount = getActiveAccount({ accounts });
if (!activeAccount) {
throw new Error('Can not find active account');
}
let { available } = accounts;
if (highlightActiveAccount) {
available = available.filter(account => account.id !== activeAccount.id);
}
return (
<div
className={classNames(
styles.accountSwitcher,
styles[`${skin}AccountSwitcher`],
)}
>
{highlightActiveAccount ? (
<div className={styles.item}>
<div
className={classNames(
styles.accountIcon,
styles.activeAccountIcon,
styles.accountIcon1,
)}
/>
<div className={styles.activeAccountInfo}>
<div className={styles.activeAccountUsername}>
{activeAccount.username}
</div>
<div
className={classNames(
styles.accountEmail,
styles.activeAccountEmail,
)}
>
{activeAccount.email}
</div>
<div className={styles.links}>
<div className={styles.link}>
<a
href={`http://ely.by/u${activeAccount.id}`}
target="_blank"
>
<Message {...messages.goToEly} />
</a>
</div>
<div className={styles.link}>
<a
className={styles.link}
onClick={this.onRemove(activeAccount)}
href="#"
>
<Message {...messages.logout} />
</a>
</div>
</div>
</div>
</div>
) : null}
{available.map((account, index) => (
<div
className={classNames(styles.item, styles.accountSwitchItem)}
key={account.id}
onClick={this.onSwitch(account)}
>
<div
className={classNames(
styles.accountIcon,
styles[
`accountIcon${(index % 7) + (highlightActiveAccount ? 2 : 1)}`
],
)}
/>
{allowLogout ? (
<div
className={styles.logoutIcon}
onClick={this.onRemove(account)}
/>
) : (
<div className={styles.nextIcon} />
)}
<div className={styles.accountInfo}>
<div className={styles.accountUsername}>{account.username}</div>
<div className={styles.accountEmail}>{account.email}</div>
</div>
</div>
))}
{allowAdd ? (
<Link to="/login" onClick={this.props.onAfterAction}>
<Button
color={COLOR_WHITE}
block
small
className={styles.addAccount}
label={
<Message {...messages.addAccount}>
{message => (
<span>
<div className={styles.addIcon} />
{message}
</span>
)}
</Message>
}
/>
</Link>
) : null}
</div>
);
}
onSwitch = (account: Account) => (event: React.MouseEvent) => {
event.preventDefault();
loader.show();
this.props
.switchAccount(account)
.finally(() => this.props.onAfterAction())
.then(() => this.props.onSwitch(account))
// we won't sent any logs to sentry, because an error should be already
// handled by external logic
.catch(error => console.warn('Error switching account', { error }))
.finally(() => loader.hide());
};
onRemove = (account: Account) => (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
this.props.removeAccount(account).then(() => this.props.onAfterAction());
};
}
export default connect(
({ accounts }: RootState) => ({
accounts,
}),
{
switchAccount: authenticate,
removeAccount: revoke,
},
)(AccountSwitcher);

View File

@@ -0,0 +1,225 @@
@import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss';
// TODO: эту константу можно заимпортить из panel.scss, но это приводит к странным ошибкам
//@import '~app/components/ui/panel.scss';
$bodyLeftRightPadding: 20px;
$lightBorderColor: #eee;
.accountSwitcher {
text-align: left;
}
.accountInfo {
}
.accountUsername,
.accountEmail {
overflow: hidden;
text-overflow: ellipsis;
}
.lightAccountSwitcher {
background: #fff;
color: #444;
min-width: 205px;
$border: 1px solid $lightBorderColor;
border-left: $border;
border-right: $border;
border-bottom: 7px solid darker($green);
.item {
padding: 15px;
border-bottom: 1px solid $lightBorderColor;
}
.accountSwitchItem {
cursor: pointer;
transition: 0.25s;
&:hover {
background-color: $whiteButtonLight;
}
&:active {
background-color: $whiteButtonDark;
}
}
.accountIcon {
font-size: 27px;
width: 20px;
text-align: center;
}
.activeAccountIcon {
font-size: 40px;
}
.activeAccountInfo {
margin-left: 29px;
}
.activeAccountUsername {
font-family: $font-family-title;
font-size: 20px;
color: $green;
}
.activeAccountEmail {
}
.links {
margin-top: 6px;
}
.link {
font-size: 12px;
margin-bottom: 3px;
white-space: nowrap;
&:last-of-type {
margin-bottom: 0;
}
}
.accountInfo {
margin-left: 29px;
margin-right: 25px;
}
.accountUsername {
font-family: $font-family-title;
font-size: 14px;
color: #666;
}
.accountEmail {
font-size: 10px;
color: #999;
}
.addAccount {
}
}
.darkAccountSwitcher {
background: $black;
$border: 1px solid lighter($black);
.item {
padding: 15px 20px;
border-top: 1px solid lighter($black);
transition: 0.25s;
cursor: pointer;
&:hover {
background-color: lighter($black);
}
&:active {
background-color: darker($black);
}
&:last-of-type {
border-bottom: $border;
}
}
.accountIcon {
font-size: 35px;
}
.accountInfo {
margin-left: 30px;
margin-right: 26px;
}
.accountUsername {
font-family: $font-family-title;
color: #fff;
}
.accountEmail {
color: #666;
font-size: 12px;
}
}
.accountIcon {
composes: minecraft-character from '~app/components/ui/icons.scss';
float: left;
&1 {
color: $green;
}
&2 {
color: $blue;
}
&3 {
color: $violet;
}
&4 {
color: $orange;
}
&5 {
color: $dark_blue;
}
&6 {
color: $light_violet;
}
&7 {
color: $red;
}
}
.addIcon {
composes: plus from '~app/components/ui/icons.scss';
color: $green;
position: relative;
bottom: 1px;
margin-right: 3px;
}
.nextIcon {
composes: arrowRight from '~app/components/ui/icons.scss';
position: relative;
float: right;
font-size: 24px;
color: #4e4e4e;
line-height: 35px;
left: 0;
transition: color 0.25s, left 0.5s;
.item:hover & {
color: #aaa;
left: 5px;
}
}
.logoutIcon {
composes: exit from '~app/components/ui/icons.scss';
color: #cdcdcd;
float: right;
line-height: 27px;
transition: 0.25s;
&:hover {
color: #777;
}
}

View File

@@ -0,0 +1,497 @@
import expect from 'app/test/unexpected';
import sinon from 'sinon';
import { browserHistory } from 'app/services/history';
import { InternalServerError } from 'app/services/request';
import { sessionStorage } from 'app/services/localStorage';
import * as authentication from 'app/services/api/authentication';
import {
authenticate,
revoke,
logoutAll,
logoutStrangers,
} from 'app/components/accounts/actions';
import {
add,
ADD,
activate,
ACTIVATE,
remove,
reset,
} from 'app/components/accounts/actions/pure-actions';
import { SET_LOCALE } from 'app/components/i18n/actions';
import { updateUser, setUser } from 'app/components/user/actions';
import { setLogin, setAccountSwitcher } from 'app/components/auth/actions';
import { Dispatch, RootState } from 'app/reducers';
import { Account } from './reducer';
const token =
'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJlbHl8MSJ9.pRJ7vakt2eIscjqwG__KhSxKb3qwGsdBBeDbBffJs_I';
const legacyToken =
'eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOjF9.cRF-sQNrwWQ94xCb3vWioVdjxAZeefEE7GMGwh7708o';
const account = {
id: 1,
username: 'username',
email: 'email@test.com',
token,
refreshToken: 'bar',
};
const user = {
id: 1,
username: 'username',
email: 'email@test.com',
lang: 'be',
};
describe('components/accounts/actions', () => {
let dispatch: Dispatch;
let getState: () => RootState;
beforeEach(() => {
dispatch = sinon
.spy(arg => (typeof arg === 'function' ? arg(dispatch, getState) : arg))
.named('store.dispatch');
getState = sinon.stub().named('store.getState');
(getState as any).returns({
accounts: {
available: [],
active: null,
},
auth: {
credentials: {},
},
user: {},
});
sinon
.stub(authentication, 'validateToken')
.named('authentication.validateToken');
sinon.stub(browserHistory, 'push').named('browserHistory.push');
sinon.stub(authentication, 'logout').named('authentication.logout');
(authentication.logout as any).returns(Promise.resolve());
(authentication.validateToken as any).returns(
Promise.resolve({
token: account.token,
refreshToken: account.refreshToken,
user,
}),
);
});
afterEach(() => {
(authentication.validateToken as any).restore();
(authentication.logout as any).restore();
(browserHistory.push as any).restore();
});
describe('#authenticate()', () => {
it('should request user state using token', () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(authentication.validateToken, 'to have a call satisfying', [
account.id,
account.token,
account.refreshToken,
]),
));
it('should request user by extracting id from token', () =>
authenticate({ token } as Account)(
dispatch,
getState,
undefined,
).then(() =>
expect(authentication.validateToken, 'to have a call satisfying', [
1,
token,
undefined,
]),
));
it('should request user by extracting id from legacy token', () =>
authenticate({ token: legacyToken } as Account)(
dispatch,
getState,
undefined,
).then(() =>
expect(authentication.validateToken, 'to have a call satisfying', [
1,
legacyToken,
undefined,
]),
));
it(`dispatches ${ADD} action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [add(account)]),
));
it(`dispatches ${ACTIVATE} action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]),
));
it(`dispatches ${SET_LOCALE} action`, () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [
{ type: SET_LOCALE, payload: { locale: 'be' } },
]),
));
it('should update user state', () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [
updateUser({ ...user, isGuest: false }),
]),
));
it('resolves with account', () =>
authenticate(account)(dispatch, getState, undefined).then(resp =>
expect(resp, 'to equal', account),
));
it('rejects when bad auth data', () => {
(authentication.validateToken as any).returns(Promise.reject({}));
return expect(
authenticate(account)(dispatch, getState, undefined),
'to be rejected',
).then(() => {
expect(dispatch, 'to have a call satisfying', [
setLogin(account.email),
]);
expect(browserHistory.push, 'to have a call satisfying', ['/login']);
});
});
it('rejects when 5xx without logouting', () => {
const resp = new InternalServerError('500', { status: 500 });
(authentication.validateToken as any).rejects(resp);
return expect(
authenticate(account)(dispatch, getState, undefined),
'to be rejected with',
resp,
).then(() =>
expect(dispatch, 'to have no calls satisfying', [
{ payload: { isGuest: true } },
]),
);
});
it('marks user as stranger, if there is no refreshToken', () => {
const expectedKey = `stranger${account.id}`;
(authentication.validateToken as any).resolves({
token: account.token,
user,
});
sessionStorage.removeItem(expectedKey);
return authenticate(account)(dispatch, getState, undefined).then(() => {
expect(sessionStorage.getItem(expectedKey), 'not to be null');
sessionStorage.removeItem(expectedKey);
});
});
describe('when user authenticated during oauth', () => {
beforeEach(() => {
(getState as any).returns({
accounts: {
available: [],
active: null,
},
user: {},
auth: {
oauth: {
clientId: 'ely.by',
prompt: [],
},
},
});
});
it('should dispatch setAccountSwitcher', () =>
authenticate(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [
setAccountSwitcher(false),
]),
));
});
describe('when one account available', () => {
beforeEach(() => {
(getState as any).returns({
accounts: {
active: account.id,
available: [account],
},
auth: {
credentials: {},
},
user,
});
});
it('should activate account before auth api call', () => {
(authentication.validateToken as any).returns(
Promise.reject({ error: 'foo' }),
);
return expect(
authenticate(account)(dispatch, getState, undefined),
'to be rejected with',
{ error: 'foo' },
).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]),
);
});
});
});
describe('#revoke()', () => {
describe('when one account available', () => {
beforeEach(() => {
(getState as any).returns({
accounts: {
active: account.id,
available: [account],
},
auth: {
credentials: {},
},
user,
});
});
it('should dispatch reset action', () =>
revoke(account)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [reset()]),
));
it('should call logout api method in background', () =>
revoke(account)(dispatch, getState, undefined).then(() =>
expect(authentication.logout, 'to have a call satisfying', [
account.token,
]),
));
it('should update user state', () =>
revoke(account)(dispatch, getState, undefined).then(
() =>
expect(dispatch, 'to have a call satisfying', [
setUser({ isGuest: true }),
]),
// expect(dispatch, 'to have calls satisfying', [
// [remove(account)],
// [expect.it('to be a function')]
// // [logout()] // TODO: this is not a plain action. How should we simplify its testing?
// ])
));
});
describe('when multiple accounts available', () => {
const account2 = { ...account, id: 2 };
beforeEach(() => {
(getState as any).returns({
accounts: {
active: account2.id,
available: [account, account2],
},
user,
});
});
it('should switch to the next account', () =>
revoke(account2)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]),
));
it('should remove current account', () =>
revoke(account2)(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [remove(account2)]),
));
it('should call logout api method in background', () =>
revoke(account2)(dispatch, getState, undefined).then(() =>
expect(authentication.logout, 'to have a call satisfying', [
account2.token,
]),
));
});
});
describe('#logoutAll()', () => {
const account2 = { ...account, id: 2 };
beforeEach(() => {
(getState as any).returns({
accounts: {
active: account2.id,
available: [account, account2],
},
auth: {
credentials: {},
},
user,
});
});
it('should call logout api method for each account', () => {
logoutAll()(dispatch, getState, undefined);
expect(authentication.logout, 'to have calls satisfying', [
[account.token],
[account2.token],
]);
});
it('should dispatch reset', () => {
logoutAll()(dispatch, getState, undefined);
expect(dispatch, 'to have a call satisfying', [reset()]);
});
it('should redirect to /login', () =>
logoutAll()(dispatch, getState, undefined).then(() => {
expect(browserHistory.push, 'to have a call satisfying', ['/login']);
}));
it('should change user to guest', () =>
logoutAll()(dispatch, getState, undefined).then(() => {
expect(dispatch, 'to have a call satisfying', [
setUser({
lang: user.lang,
isGuest: true,
}),
]);
}));
});
describe('#logoutStrangers', () => {
const foreignAccount = {
...account,
id: 2,
refreshToken: null,
};
const foreignAccount2 = {
...foreignAccount,
id: 3,
};
beforeEach(() => {
(getState as any).returns({
accounts: {
active: foreignAccount.id,
available: [account, foreignAccount, foreignAccount2],
},
user,
});
});
it('should remove stranger accounts', () => {
logoutStrangers()(dispatch, getState, undefined);
expect(dispatch, 'to have a call satisfying', [remove(foreignAccount)]);
expect(dispatch, 'to have a call satisfying', [remove(foreignAccount2)]);
});
it('should logout stranger accounts', () => {
logoutStrangers()(dispatch, getState, undefined);
expect(authentication.logout, 'to have calls satisfying', [
[foreignAccount.token],
[foreignAccount2.token],
]);
});
it('should activate another account if available', () =>
logoutStrangers()(dispatch, getState, undefined).then(() =>
expect(dispatch, 'to have a call satisfying', [activate(account)]),
));
it('should not activate another account if active account is already not a stranger', () => {
(getState as any).returns({
accounts: {
active: account.id,
available: [account, foreignAccount],
},
user,
});
return logoutStrangers()(dispatch, getState, undefined).then(() =>
expect(dispatch, 'not to have calls satisfying', [activate(account)]),
);
});
it('should not dispatch if no strangers', () => {
(getState as any).returns({
accounts: {
active: account.id,
available: [account],
},
user,
});
return logoutStrangers()(dispatch, getState, undefined).then(() =>
expect(dispatch, 'was not called'),
);
});
describe('when all accounts are strangers', () => {
beforeEach(() => {
(getState as any).returns({
accounts: {
active: foreignAccount.id,
available: [foreignAccount, foreignAccount2],
},
auth: {
credentials: {},
},
user,
});
logoutStrangers()(dispatch, getState, undefined);
});
it('logouts all accounts', () => {
expect(authentication.logout, 'to have calls satisfying', [
[foreignAccount.token],
[foreignAccount2.token],
]);
expect(dispatch, 'to have a call satisfying', [
setUser({ isGuest: true }),
]);
expect(dispatch, 'to have a call satisfying', [reset()]);
});
});
describe('when a stranger has a mark in sessionStorage', () => {
const key = `stranger${foreignAccount.id}`;
beforeEach(() => {
sessionStorage.setItem(key, 1);
logoutStrangers()(dispatch, getState, undefined);
});
afterEach(() => {
sessionStorage.removeItem(key);
});
it('should not log out', () =>
expect(dispatch, 'not to have calls satisfying', [
{ payload: foreignAccount },
]));
});
});
});

View File

@@ -0,0 +1,354 @@
import { getJwtPayloads } from 'app/functions';
import { sessionStorage } from 'app/services/localStorage';
import {
validateToken,
requestToken,
logout,
} from 'app/services/api/authentication';
import { relogin as navigateToLogin } from 'app/components/auth/actions';
import { updateUser, setGuest } from 'app/components/user/actions';
import { setLocale } from 'app/components/i18n/actions';
import { setAccountSwitcher } from 'app/components/auth/actions';
import { getActiveAccount } from 'app/components/accounts/reducer';
import logger from 'app/services/logger';
import { ThunkAction } from 'app/reducers';
import { Account } from './reducer';
import {
add,
remove,
activate,
reset,
updateToken,
} from './actions/pure-actions';
export { updateToken, activate, remove };
/**
* @param {Account|object} account
* @param {string} account.token
* @param {string} account.refreshToken
*
* @returns {Function}
*/
export function authenticate(
account:
| Account
| {
token: string;
refreshToken: string | null;
},
): ThunkAction<Promise<Account>> {
const { token, refreshToken } = account;
const email = 'email' in account ? account.email : null;
return async (dispatch, getState) => {
let accountId: number;
if ('id' in account && typeof account.id === 'number') {
accountId = account.id;
} else {
accountId = findAccountIdFromToken(token);
}
const knownAccount = getState().accounts.available.find(
item => item.id === accountId,
);
if (knownAccount) {
// this account is already available
// activate it before validation
dispatch(activate(knownAccount));
}
try {
const {
token: newToken,
refreshToken: newRefreshToken,
user,
} = await validateToken(accountId, token, refreshToken);
const { auth } = getState();
const newAccount: Account = {
id: user.id,
username: user.username,
email: user.email,
token: newToken,
refreshToken: newRefreshToken,
};
dispatch(add(newAccount));
dispatch(activate(newAccount));
dispatch(
updateUser({
isGuest: false,
...user,
}),
);
// TODO: probably should be moved from here, because it is a side effect
logger.setUser(user);
if (!newRefreshToken) {
// mark user as stranger (user does not want us to remember his account)
sessionStorage.setItem(`stranger${newAccount.id}`, 1);
}
if (auth && auth.oauth && auth.oauth.clientId) {
// if we authenticating during oauth, we disable account chooser
// because user probably has made his choise now
// this may happen, when user registers, logs in or uses account
// chooser panel during oauth
dispatch(setAccountSwitcher(false));
}
await dispatch(setLocale(user.lang));
return newAccount;
} catch (resp) {
// all the logic to get the valid token was failed,
// looks like we have some problems with token
// lets redirect to login page
if (typeof email === 'string') {
// TODO: we should somehow try to find email by token
dispatch(relogin(email));
}
throw resp;
}
};
}
function findAccountIdFromToken(token: string): number {
const { sub, jti } = getJwtPayloads(token);
// sub has the format "ely|{accountId}", so we must trim "ely|" part
if (sub) {
return parseInt(sub.substr(4), 10);
}
// In older backend versions identity was stored in jti claim. Some users still have such tokens
if (jti) {
return jti;
}
throw new Error('payloads is not contains any identity claim');
}
/**
* Checks the current user's token exp time. Supposed to be used before performing
* any api request
*
* @see components/user/middlewares/refreshTokenMiddleware
*
* @returns {Function}
*/
export function ensureToken(): ThunkAction<Promise<void>> {
return (dispatch, getState) => {
const { token } = getActiveAccount(getState()) || {};
try {
const SAFETY_FACTOR = 300; // ask new token earlier to overcome time desynchronization problem
const { exp } = getJwtPayloads(token as any);
if (exp - SAFETY_FACTOR < Date.now() / 1000) {
return dispatch(requestNewToken());
}
} catch (err) {
logger.warn('Refresh token error: bad token', {
token,
});
dispatch(relogin());
return Promise.reject(new Error('Invalid token'));
}
return Promise.resolve();
};
}
/**
* Checks whether request `error` is an auth error and tries recover from it by
* requesting a new auth token
*
* @see components/user/middlewares/refreshTokenMiddleware
*
* @param {object} error
*
* @returns {Function}
*/
export function recoverFromTokenError(
error: {
status: number;
message: string;
} | void,
): ThunkAction<Promise<void>> {
return (dispatch, getState) => {
if (error && error.status === 401) {
const activeAccount = getActiveAccount(getState());
if (activeAccount && activeAccount.refreshToken) {
if (
[
'Token expired',
'Incorrect token',
'You are requesting with an invalid credential.',
].includes(error.message)
) {
// request token and retry
return dispatch(requestNewToken());
}
logger.error('Unknown unauthorized response', {
error,
});
}
// user's access token is outdated and we have no refreshToken
// or something unexpected happend
// in both cases we resetting all the user's state
dispatch(relogin());
}
return Promise.reject(error);
};
}
/**
* Requests new token and updates state. In case, when token can not be updated,
* it will redirect user to login page
*
* @returns {Function}
*/
export function requestNewToken(): ThunkAction<Promise<void>> {
return (dispatch, getState) => {
const { refreshToken } = getActiveAccount(getState()) || {};
if (!refreshToken) {
dispatch(relogin());
return Promise.resolve();
}
return requestToken(refreshToken)
.then(token => {
dispatch(updateToken(token));
})
.catch(resp => {
// all the logic to get the valid token was failed,
// looks like we have some problems with token
// lets redirect to login page
dispatch(relogin());
return Promise.reject(resp);
});
};
}
/**
* Remove one account from current user's account list
*
* @param {Account} account
*
* @returns {Function}
*/
export function revoke(account: Account): ThunkAction<Promise<void>> {
return async (dispatch, getState) => {
const accountToReplace: Account | null =
getState().accounts.available.find(({ id }) => id !== account.id) || null;
if (accountToReplace) {
await dispatch(authenticate(accountToReplace))
.finally(() => {
// we need to logout user, even in case, when we can
// not authenticate him with new account
logout(account.token).catch(() => {
// we don't care
});
dispatch(remove(account));
})
.catch(() => {
// we don't care
});
return;
}
return dispatch(logoutAll());
};
}
export function relogin(email?: string): ThunkAction<Promise<void>> {
return async (dispatch, getState) => {
const activeAccount = getActiveAccount(getState());
if (!email && activeAccount) {
email = activeAccount.email;
}
dispatch(navigateToLogin(email || null));
};
}
export function logoutAll(): ThunkAction<Promise<void>> {
return (dispatch, getState) => {
dispatch(setGuest());
const {
accounts: { available },
} = getState();
available.forEach(account =>
logout(account.token).catch(() => {
// we don't care
}),
);
dispatch(reset());
dispatch(relogin());
return Promise.resolve();
};
}
/**
* Logouts accounts, that was marked as "do not remember me"
*
* We detecting foreign accounts by the absence of refreshToken. The account
* won't be removed, until key `stranger${account.id}` is present in sessionStorage
*
* @returns {Function}
*/
export function logoutStrangers(): ThunkAction<Promise<void>> {
return async (dispatch, getState) => {
const {
accounts: { available },
} = getState();
const activeAccount = getActiveAccount(getState());
const isStranger = ({ refreshToken, id }: Account) =>
!refreshToken && !sessionStorage.getItem(`stranger${id}`);
if (available.some(isStranger)) {
const accountToReplace = available.find(account => !isStranger(account));
if (accountToReplace) {
available.filter(isStranger).forEach(account => {
dispatch(remove(account));
logout(account.token);
});
if (activeAccount && isStranger(activeAccount)) {
await dispatch(authenticate(accountToReplace));
return;
}
} else {
await dispatch(logoutAll());
return;
}
}
return Promise.resolve();
};
}

View File

@@ -0,0 +1,78 @@
import {
Account,
AddAction,
RemoveAction,
ActivateAction,
UpdateTokenAction,
ResetAction,
} from '../reducer';
export const ADD = 'accounts:add';
/**
* @private
*
* @param {Account} account
*
* @returns {object} - action definition
*/
export function add(account: Account): AddAction {
return {
type: ADD,
payload: account,
};
}
export const REMOVE = 'accounts:remove';
/**
* @private
*
* @param {Account} account
*
* @returns {object} - action definition
*/
export function remove(account: Account): RemoveAction {
return {
type: REMOVE,
payload: account,
};
}
export const ACTIVATE = 'accounts:activate';
/**
* @private
*
* @param {Account} account
*
* @returns {object} - action definition
*/
export function activate(account: Account): ActivateAction {
return {
type: ACTIVATE,
payload: account,
};
}
export const RESET = 'accounts:reset';
/**
* @private
*
* @returns {object} - action definition
*/
export function reset(): ResetAction {
return {
type: RESET,
};
}
export const UPDATE_TOKEN = 'accounts:updateToken';
/**
* @param {string} token
*
* @returns {object} - action definition
*/
export function updateToken(token: string): UpdateTokenAction {
return {
type: UPDATE_TOKEN,
payload: token,
};
}

View File

@@ -0,0 +1,2 @@
export { State as AccountsState, Account } from './reducer';
export { default as AccountSwitcher } from './AccountSwitcher';

View File

@@ -0,0 +1,162 @@
import expect from 'app/test/unexpected';
import { updateToken } from './actions';
import {
add,
remove,
activate,
reset,
ADD,
REMOVE,
ACTIVATE,
UPDATE_TOKEN,
RESET,
} from './actions/pure-actions';
import accounts, { Account } from './reducer';
const account: Account = {
id: 1,
username: 'username',
email: 'email@test.com',
token: 'foo',
} as Account;
describe('Accounts reducer', () => {
let initial;
beforeEach(() => {
initial = accounts(undefined, {} as any);
});
it('should be empty', () =>
expect(accounts(undefined, {} as any), 'to equal', {
active: null,
available: [],
}));
it('should return last state if unsupported action', () =>
expect(accounts({ state: 'foo' } as any, {} as any), 'to equal', {
state: 'foo',
}));
describe(ACTIVATE, () => {
it('sets active account', () => {
expect(accounts(initial, activate(account)), 'to satisfy', {
active: account.id,
});
});
});
describe(ADD, () => {
it('adds an account', () =>
expect(accounts(initial, add(account)), 'to satisfy', {
available: [account],
}));
it('should replace if account was added for the second time', () => {
const outdatedAccount = {
...account,
someShit: true,
};
const updatedAccount = {
...account,
token: 'newToken',
};
expect(
accounts(
{ ...initial, available: [outdatedAccount] },
add(updatedAccount),
),
'to satisfy',
{
available: [updatedAccount],
},
);
});
it('should sort accounts by username', () => {
const newAccount = {
...account,
id: 2,
username: 'abc',
};
expect(
accounts({ ...initial, available: [account] }, add(newAccount)),
'to satisfy',
{
available: [newAccount, account],
},
);
});
it('throws, when account is invalid', () => {
expect(
() =>
accounts(
initial,
// @ts-ignore
add(),
),
'to throw',
'Invalid or empty payload passed for accounts.add',
);
});
});
describe(REMOVE, () => {
it('should remove an account', () =>
expect(
accounts({ ...initial, available: [account] }, remove(account)),
'to equal',
initial,
));
it('throws, when account is invalid', () => {
expect(
() =>
accounts(
initial,
// @ts-ignore
remove(),
),
'to throw',
'Invalid or empty payload passed for accounts.remove',
);
});
});
describe(RESET, () => {
it('should reset accounts state', () =>
expect(
accounts({ ...initial, available: [account] }, reset()),
'to equal',
initial,
));
});
describe(UPDATE_TOKEN, () => {
it('should update token', () => {
const newToken = 'newToken';
expect(
accounts(
{ active: account.id, available: [account] },
updateToken(newToken),
),
'to satisfy',
{
active: account.id,
available: [
{
...account,
token: newToken,
},
],
},
);
});
});
});

View File

@@ -0,0 +1,136 @@
export type Account = {
id: number;
username: string;
email: string;
token: string;
refreshToken: string | null;
};
export type State = {
active: number | null;
available: Account[];
};
export type AddAction = { type: 'accounts:add'; payload: Account };
export type RemoveAction = { type: 'accounts:remove'; payload: Account };
export type ActivateAction = { type: 'accounts:activate'; payload: Account };
export type UpdateTokenAction = {
type: 'accounts:updateToken';
payload: string;
};
export type ResetAction = { type: 'accounts:reset' };
type Action =
| AddAction
| RemoveAction
| ActivateAction
| UpdateTokenAction
| ResetAction;
export function getActiveAccount(state: { accounts: State }): Account | null {
const accountId = state.accounts.active;
return (
state.accounts.available.find(account => account.id === accountId) || null
);
}
export function getAvailableAccounts(state: {
accounts: State;
}): Array<Account> {
return state.accounts.available;
}
export default function accounts(
state: State = {
active: null,
available: [],
},
action: Action,
): State {
switch (action.type) {
case 'accounts:add': {
if (!action.payload || !action.payload.id || !action.payload.token) {
throw new Error('Invalid or empty payload passed for accounts.add');
}
const { payload } = action;
state.available = state.available
.filter(account => account.id !== payload.id)
.concat(payload);
state.available.sort((account1, account2) => {
if (account1.username === account2.username) {
return 0;
}
return account1.username > account2.username ? 1 : -1;
});
return state;
}
case 'accounts:activate': {
if (!action.payload || !action.payload.id || !action.payload.token) {
throw new Error('Invalid or empty payload passed for accounts.add');
}
const { payload } = action;
return {
available: state.available.map(account => {
if (account.id === payload.id) {
return { ...payload };
}
return { ...account };
}),
active: payload.id,
};
}
case 'accounts:reset':
return {
active: null,
available: [],
};
case 'accounts:remove': {
if (!action.payload || !action.payload.id) {
throw new Error('Invalid or empty payload passed for accounts.remove');
}
const { payload } = action;
return {
...state,
available: state.available.filter(account => account.id !== payload.id),
};
}
case 'accounts:updateToken': {
if (typeof action.payload !== 'string') {
throw new Error('payload must be a jwt token');
}
const { payload } = action;
return {
...state,
available: state.available.map(account => {
if (account.id === state.active) {
return {
...account,
token: payload,
};
}
return { ...account };
}),
};
}
}
return state;
}