mirror of
https://github.com/elyby/accounts-frontend.git
synced 2025-05-31 14:11:58 +05:30
Create app namespace for all absolute requires of app modules. Move all packages under packages yarn workspace
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"addAccount": "Add account",
|
||||
"goToEly": "Go to Ely.by profile",
|
||||
"logout": "Log out"
|
||||
}
|
198
packages/app/components/accounts/AccountSwitcher.tsx
Normal file
198
packages/app/components/accounts/AccountSwitcher.tsx
Normal 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);
|
225
packages/app/components/accounts/accountSwitcher.scss
Normal file
225
packages/app/components/accounts/accountSwitcher.scss
Normal 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;
|
||||
}
|
||||
}
|
497
packages/app/components/accounts/actions.test.ts
Normal file
497
packages/app/components/accounts/actions.test.ts
Normal 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 },
|
||||
]));
|
||||
});
|
||||
});
|
||||
});
|
354
packages/app/components/accounts/actions.ts
Normal file
354
packages/app/components/accounts/actions.ts
Normal 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();
|
||||
};
|
||||
}
|
78
packages/app/components/accounts/actions/pure-actions.ts
Normal file
78
packages/app/components/accounts/actions/pure-actions.ts
Normal 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,
|
||||
};
|
||||
}
|
2
packages/app/components/accounts/index.ts
Normal file
2
packages/app/components/accounts/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { State as AccountsState, Account } from './reducer';
|
||||
export { default as AccountSwitcher } from './AccountSwitcher';
|
162
packages/app/components/accounts/reducer.test.ts
Normal file
162
packages/app/components/accounts/reducer.test.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
136
packages/app/components/accounts/reducer.ts
Normal file
136
packages/app/components/accounts/reducer.ts
Normal 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;
|
||||
}
|
Reference in New Issue
Block a user