mirror of
https://github.com/elyby/accounts-frontend.git
synced 2024-12-23 13:39:54 +05:30
Split the AccountSwitcher component on 2 independent implementations
Improve auth's ChooseAccount visual behavior Rework the ComponentLoader component. Make it more stylable
This commit is contained in:
parent
c58816212d
commit
8075192472
@ -1,166 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { FormattedMessage as Message } from 'react-intl';
|
|
||||||
|
|
||||||
import { connect } from 'app/functions';
|
|
||||||
import * as 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 { State as AccountState } from 'app/components/accounts/reducer';
|
|
||||||
|
|
||||||
import styles from './accountSwitcher.scss';
|
|
||||||
|
|
||||||
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: AccountState;
|
|
||||||
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) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { available } = accounts;
|
|
||||||
|
|
||||||
if (highlightActiveAccount) {
|
|
||||||
available = available.filter((account) => account.id !== activeAccount.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(styles.accountSwitcher, styles[`${skin}AccountSwitcher`])}
|
|
||||||
data-testid="account-switcher"
|
|
||||||
>
|
|
||||||
{highlightActiveAccount && (
|
|
||||||
<div className={styles.item} data-testid="active-account">
|
|
||||||
<div className={clsx(styles.accountIcon, styles.activeAccountIcon, styles.accountIcon1)} />
|
|
||||||
<div className={styles.activeAccountInfo}>
|
|
||||||
<div className={styles.activeAccountUsername}>{activeAccount.username}</div>
|
|
||||||
<div className={clsx(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 key="goToEly" defaultMessage="Go to Ely.by profile" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className={styles.link}>
|
|
||||||
<a
|
|
||||||
className={styles.link}
|
|
||||||
data-testid="logout-account"
|
|
||||||
onClick={this.onRemove(activeAccount)}
|
|
||||||
href="#"
|
|
||||||
>
|
|
||||||
<Message key="logout" defaultMessage="Log out" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{available.map((account, index) => (
|
|
||||||
<div
|
|
||||||
className={clsx(styles.item, styles.accountSwitchItem)}
|
|
||||||
key={account.id}
|
|
||||||
data-e2e-account-id={account.id}
|
|
||||||
onClick={this.onSwitch(account)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={clsx(
|
|
||||||
styles.accountIcon,
|
|
||||||
styles[`accountIcon${(index % 7) + (highlightActiveAccount ? 2 : 1)}`],
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{allowLogout ? (
|
|
||||||
<div
|
|
||||||
className={styles.logoutIcon}
|
|
||||||
data-testid="logout-account"
|
|
||||||
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} data-testid="add-account" block small className={styles.addAccount}>
|
|
||||||
<span>
|
|
||||||
<div className={styles.addIcon} />
|
|
||||||
<Message key="addAccount" defaultMessage="Add account" />
|
|
||||||
</span>
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
onSwitch = (account: Account) => (event: React.MouseEvent<any>) => {
|
|
||||||
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<any>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
this.props.removeAccount(account).then(() => this.props.onAfterAction());
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(
|
|
||||||
({ accounts }) => ({
|
|
||||||
accounts,
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
switchAccount: authenticate,
|
|
||||||
removeAccount: revoke,
|
|
||||||
},
|
|
||||||
)(AccountSwitcher);
|
|
10
packages/app/components/accounts/accountSwitcher.intl.ts
Normal file
10
packages/app/components/accounts/accountSwitcher.intl.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { defineMessages } from 'react-intl';
|
||||||
|
|
||||||
|
// Extract this messages to this file to keep the messages prefix
|
||||||
|
const messages = defineMessages({
|
||||||
|
goToEly: 'Go to Ely.by profile',
|
||||||
|
logout: 'Log out',
|
||||||
|
addAccount: 'Add account',
|
||||||
|
});
|
||||||
|
|
||||||
|
export default messages;
|
@ -1,225 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
|
@ -7,7 +7,7 @@ import * as authentication from 'app/services/api/authentication';
|
|||||||
import { authenticate, revoke, logoutAll, logoutStrangers } from 'app/components/accounts/actions';
|
import { authenticate, revoke, logoutAll, logoutStrangers } from 'app/components/accounts/actions';
|
||||||
import { add, activate, remove, reset } from 'app/components/accounts/actions/pure-actions';
|
import { add, activate, remove, reset } from 'app/components/accounts/actions/pure-actions';
|
||||||
import { updateUser, setUser } from 'app/components/user/actions';
|
import { updateUser, setUser } from 'app/components/user/actions';
|
||||||
import { setLogin, setAccountSwitcher } from 'app/components/auth/actions';
|
import { setLogin } from 'app/components/auth/actions';
|
||||||
import { Dispatch, State as RootState } from 'app/types';
|
import { Dispatch, State as RootState } from 'app/types';
|
||||||
|
|
||||||
import { Account } from './reducer';
|
import { Account } from './reducer';
|
||||||
@ -182,11 +182,6 @@ describe('components/accounts/actions', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should dispatch setAccountSwitcher', () =>
|
|
||||||
authenticate(account)(dispatch, getState, undefined).then(() =>
|
|
||||||
expect(dispatch, 'to have a call satisfying', [setAccountSwitcher(false)]),
|
|
||||||
));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('when one account available', () => {
|
describe('when one account available', () => {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { getJwtPayloads } from 'app/functions';
|
import { getJwtPayloads } from 'app/functions';
|
||||||
import { sessionStorage } from 'app/services/localStorage';
|
import { sessionStorage } from 'app/services/localStorage';
|
||||||
import { validateToken, requestToken, logout } from 'app/services/api/authentication';
|
import { validateToken, requestToken, logout } from 'app/services/api/authentication';
|
||||||
import { relogin as navigateToLogin, setAccountSwitcher } from 'app/components/auth/actions';
|
import { relogin as navigateToLogin } from 'app/components/auth/actions';
|
||||||
import { updateUser, setGuest } from 'app/components/user/actions';
|
import { updateUser, setGuest } from 'app/components/user/actions';
|
||||||
import { setLocale } from 'app/components/i18n/actions';
|
import { setLocale } from 'app/components/i18n/actions';
|
||||||
import logger from 'app/services/logger';
|
import logger from 'app/services/logger';
|
||||||
@ -53,7 +53,6 @@ export function authenticate(
|
|||||||
token,
|
token,
|
||||||
refreshToken,
|
refreshToken,
|
||||||
);
|
);
|
||||||
const { auth } = getState();
|
|
||||||
const newAccount: Account = {
|
const newAccount: Account = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
@ -78,14 +77,6 @@ export function authenticate(
|
|||||||
sessionStorage.setItem(`stranger${newAccount.id}`, '1');
|
sessionStorage.setItem(`stranger${newAccount.id}`, '1');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (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));
|
await dispatch(setLocale(user.lang));
|
||||||
|
|
||||||
return newAccount;
|
return newAccount;
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { State, Account as AccountType } from './reducer';
|
import { State, Account as AccountType } from './reducer';
|
||||||
|
|
||||||
export { default as AccountSwitcher } from './AccountSwitcher';
|
|
||||||
export type AccountsState = State;
|
export type AccountsState = State;
|
||||||
export type Account = AccountType;
|
export type Account = AccountType;
|
||||||
|
@ -8,8 +8,8 @@ export interface AuthContext {
|
|||||||
user: User;
|
user: User;
|
||||||
requestRedraw: () => Promise<void>;
|
requestRedraw: () => Promise<void>;
|
||||||
clearErrors: () => void;
|
clearErrors: () => void;
|
||||||
resolve: (payload: { [key: string]: any } | undefined) => void;
|
resolve: (payload: Record<string, any> | undefined) => Promise<any> | void;
|
||||||
reject: (payload: { [key: string]: any } | undefined) => void;
|
reject: (payload: Record<string, any> | undefined) => Promise<any> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Context = React.createContext<AuthContext>({
|
const Context = React.createContext<AuthContext>({
|
||||||
|
@ -0,0 +1,60 @@
|
|||||||
|
import React, { ComponentType, MouseEventHandler, useCallback, useState } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import { PseudoAvatar } from 'app/components/ui';
|
||||||
|
import { ComponentLoader } from 'app/components/ui/loader';
|
||||||
|
import { Account } from 'app/components/accounts/reducer';
|
||||||
|
|
||||||
|
import styles from './accountSwitcher.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
accounts: ReadonlyArray<Account>;
|
||||||
|
onAccountClick?: (account: Account) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccountSwitcher: ComponentType<Props> = ({ accounts, onAccountClick }) => {
|
||||||
|
const [selectedAccount, setSelectedAccount] = useState<number>();
|
||||||
|
const onAccountClickCallback = useCallback(
|
||||||
|
(account: Account): MouseEventHandler => async (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
setSelectedAccount(account.id);
|
||||||
|
try {
|
||||||
|
if (onAccountClick) {
|
||||||
|
await onAccountClick(account);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setSelectedAccount(undefined);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onAccountClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.accountSwitcher} data-testid="account-switcher">
|
||||||
|
{accounts.map((account, index) => (
|
||||||
|
<div
|
||||||
|
className={clsx(styles.item, {
|
||||||
|
[styles.inactiveItem]: selectedAccount && selectedAccount !== account.id,
|
||||||
|
})}
|
||||||
|
key={account.id}
|
||||||
|
data-e2e-account-id={account.id}
|
||||||
|
onClick={onAccountClickCallback(account)}
|
||||||
|
>
|
||||||
|
<PseudoAvatar index={index} className={styles.accountIcon} />
|
||||||
|
<div className={styles.accountInfo}>
|
||||||
|
<div className={styles.accountUsername}>{account.username}</div>
|
||||||
|
<div className={styles.accountEmail}>{account.email}</div>
|
||||||
|
</div>
|
||||||
|
{selectedAccount === account.id ? (
|
||||||
|
<ComponentLoader skin="light" className={styles.accountLoader} />
|
||||||
|
) : (
|
||||||
|
<div className={styles.nextIcon} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccountSwitcher;
|
@ -2,12 +2,22 @@ import React from 'react';
|
|||||||
|
|
||||||
import { FormattedMessage as Message } from 'react-intl';
|
import { FormattedMessage as Message } from 'react-intl';
|
||||||
|
|
||||||
|
import { connect } from 'app/functions';
|
||||||
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
|
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
|
||||||
import { AccountSwitcher } from 'app/components/accounts';
|
import type { Account } from 'app/components/accounts';
|
||||||
import { Account } from 'app/components/accounts/reducer';
|
|
||||||
|
|
||||||
|
import AccountSwitcher from './AccountSwitcher';
|
||||||
import styles from './chooseAccount.scss';
|
import styles from './chooseAccount.scss';
|
||||||
|
|
||||||
|
// I can't connect the ChooseAccountBody component with redux's "connect" function
|
||||||
|
// to get accounts list because it will break the TransitionMotion animation implementation.
|
||||||
|
//
|
||||||
|
// So to provide accounts list to the component, I'll create connected version of
|
||||||
|
// the composes with already provided accounts list
|
||||||
|
const ConnectedAccountSwitcher = connect((state) => ({
|
||||||
|
accounts: state.accounts.available,
|
||||||
|
}))(AccountSwitcher);
|
||||||
|
|
||||||
export default class ChooseAccountBody extends BaseAuthBody {
|
export default class ChooseAccountBody extends BaseAuthBody {
|
||||||
static displayName = 'ChooseAccountBody';
|
static displayName = 'ChooseAccountBody';
|
||||||
static panelId = 'chooseAccount';
|
static panelId = 'chooseAccount';
|
||||||
@ -29,28 +39,21 @@ export default class ChooseAccountBody extends BaseAuthBody {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.description}>
|
<Message
|
||||||
<Message
|
key="pleaseChooseAccount"
|
||||||
key="pleaseChooseAccount"
|
defaultMessage="Please select an account you're willing to use"
|
||||||
defaultMessage="Please select an account you're willing to use"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.accountSwitcherContainer}>
|
<div className={styles.accountSwitcherContainer}>
|
||||||
<AccountSwitcher
|
<ConnectedAccountSwitcher onAccountClick={this.onSwitch} />
|
||||||
allowAdd={false}
|
|
||||||
allowLogout={false}
|
|
||||||
highlightActiveAccount={false}
|
|
||||||
onSwitch={this.onSwitch}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onSwitch = (account: Account): void => {
|
onSwitch = (account: Account): Promise<void> => {
|
||||||
this.context.resolve(account);
|
return Promise.resolve(this.context.resolve(account));
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,87 @@
|
|||||||
|
@import '~app/components/ui/colors.scss';
|
||||||
|
@import '~app/components/ui/fonts.scss';
|
||||||
|
|
||||||
|
.accountSwitcher {
|
||||||
|
background: $black;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
$border: 1px solid lighter($black);
|
||||||
|
|
||||||
|
.item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-top: 1px solid lighter($black);
|
||||||
|
transition: background-color 0.25s, filter 0.5s cubic-bezier(0, 0.55, 0.45, 1);
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: lighter($black);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: darker($black);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:last-of-type {
|
||||||
|
border-bottom: $border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactiveItem {
|
||||||
|
filter: grayscale(100%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accountIcon {
|
||||||
|
font-size: 35px;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accountInfo {
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-right: 15px;
|
||||||
|
min-width: 0; // Fix for text-overflow. See https://stackoverflow.com/a/40612184
|
||||||
|
}
|
||||||
|
|
||||||
|
%overflowText {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accountUsername {
|
||||||
|
@extend %overflowText;
|
||||||
|
font-family: $font-family-title;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.accountEmail {
|
||||||
|
@extend %overflowText;
|
||||||
|
color: #666;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nextIcon {
|
||||||
|
composes: arrowRight from '~app/components/ui/icons.scss';
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
left: 0;
|
||||||
|
|
||||||
|
font-size: 24px;
|
||||||
|
color: #4e4e4e;
|
||||||
|
line-height: 35px;
|
||||||
|
|
||||||
|
transition: color 0.25s, left 0.5s;
|
||||||
|
|
||||||
|
.item:hover & {
|
||||||
|
color: #aaa;
|
||||||
|
left: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.accountLoader {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
15
packages/app/components/ui/PseudoAvatar.tsx
Normal file
15
packages/app/components/ui/PseudoAvatar.tsx
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import React, { ComponentType } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
import styles from './pseudoAvatar.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
index?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PseudoAvatar: ComponentType<Props> = ({ index = 0, className }) => (
|
||||||
|
<div className={clsx(styles.pseudoAvatar, styles[`pseudoAvatar${index % 7}`], className)} />
|
||||||
|
);
|
||||||
|
|
||||||
|
export default PseudoAvatar;
|
@ -1,7 +1,8 @@
|
|||||||
import React, { InputHTMLAttributes, MouseEventHandler } from 'react';
|
import React, { InputHTMLAttributes, MouseEventHandler } from 'react';
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
import { MessageDescriptor } from 'react-intl';
|
import { MessageDescriptor } from 'react-intl';
|
||||||
|
import ClickAwayListener from 'react-click-away-listener';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
import { COLOR_GREEN, Color } from 'app/components/ui';
|
import { COLOR_GREEN, Color } from 'app/components/ui';
|
||||||
|
|
||||||
import styles from './dropdown.scss';
|
import styles from './dropdown.scss';
|
||||||
@ -12,7 +13,7 @@ type ItemLabel = I18nString | React.ReactElement;
|
|||||||
|
|
||||||
interface Props extends InputHTMLAttributes<HTMLInputElement> {
|
interface Props extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
label: I18nString;
|
label: I18nString;
|
||||||
items: { [value: string]: ItemLabel };
|
items: Record<string, ItemLabel>;
|
||||||
block?: boolean;
|
block?: boolean;
|
||||||
color: Color;
|
color: Color;
|
||||||
}
|
}
|
||||||
@ -37,18 +38,6 @@ export default class Dropdown extends FormInputComponent<Props, State> {
|
|||||||
activeItem: null,
|
activeItem: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
// listen to capturing phase to ensure, that our event handler will be
|
|
||||||
// called before all other
|
|
||||||
// @ts-ignore
|
|
||||||
document.addEventListener('click', this.onBodyClick, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
// @ts-ignore
|
|
||||||
document.removeEventListener('click', this.onBodyClick);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { color, block, items, ...restProps } = this.props;
|
const { color, block, items, ...restProps } = this.props;
|
||||||
const { isActive } = this.state;
|
const { isActive } = this.state;
|
||||||
@ -59,7 +48,7 @@ export default class Dropdown extends FormInputComponent<Props, State> {
|
|||||||
const label = React.isValidElement(activeItem.label) ? activeItem.label : this.formatMessage(activeItem.label);
|
const label = React.isValidElement(activeItem.label) ? activeItem.label : this.formatMessage(activeItem.label);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<ClickAwayListener onClickAway={this.onCloseClick}>
|
||||||
<div
|
<div
|
||||||
className={clsx(styles[color], {
|
className={clsx(styles[color], {
|
||||||
[styles.block]: block,
|
[styles.block]: block,
|
||||||
@ -84,7 +73,7 @@ export default class Dropdown extends FormInputComponent<Props, State> {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{this.renderError()}
|
{this.renderError()}
|
||||||
</div>
|
</ClickAwayListener>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,17 +126,9 @@ export default class Dropdown extends FormInputComponent<Props, State> {
|
|||||||
this.toggle();
|
this.toggle();
|
||||||
};
|
};
|
||||||
|
|
||||||
onBodyClick: MouseEventHandler = (event) => {
|
onCloseClick = () => {
|
||||||
if (this.state.isActive) {
|
if (this.state.isActive) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
this.toggle();
|
||||||
const el = ReactDOM.findDOMNode(this)!;
|
|
||||||
|
|
||||||
if (!el.contains(event.target as HTMLElement) && el !== event.target) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
this.toggle();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -30,3 +30,4 @@ export const SKIN_LIGHT: Skin = 'light';
|
|||||||
export const skins: Array<Skin> = [SKIN_DARK, SKIN_LIGHT];
|
export const skins: Array<Skin> = [SKIN_DARK, SKIN_LIGHT];
|
||||||
|
|
||||||
export { default as RelativeTime } from './RelativeTime';
|
export { default as RelativeTime } from './RelativeTime';
|
||||||
|
export { default as PseudoAvatar } from './PseudoAvatar';
|
||||||
|
@ -9,10 +9,11 @@ import styles from './componentLoader.scss';
|
|||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
skin?: Skin;
|
skin?: Skin;
|
||||||
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ComponentLoader: ComponentType<Props> = ({ skin = 'dark' }) => (
|
const ComponentLoader: ComponentType<Props> = ({ skin = 'dark', className }) => (
|
||||||
<div className={clsx(styles.componentLoader, styles[`${skin}ComponentLoader`])}>
|
<div className={clsx(styles.componentLoader, styles[`${skin}ComponentLoader`], className)}>
|
||||||
<div className={styles.spins}>
|
<div className={styles.spins}>
|
||||||
{new Array(5).fill(0).map((_, index) => (
|
{new Array(5).fill(0).map((_, index) => (
|
||||||
<div className={clsx(styles.spin, styles[`spin${index}`])} key={index} />
|
<div className={clsx(styles.spin, styles[`spin${index}`])} key={index} />
|
||||||
|
@ -1,41 +1,31 @@
|
|||||||
@import '~app/components/ui/colors.scss';
|
@import '~app/components/ui/colors.scss';
|
||||||
|
|
||||||
.componentLoader {
|
.componentLoader {
|
||||||
width: 100%;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spins {
|
.spins {
|
||||||
height: 40px;
|
height: 2em;
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 1;
|
||||||
|
flex-basis: 0;
|
||||||
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spin {
|
.spin {
|
||||||
height: 20px;
|
height: 1em;
|
||||||
width: 20px;
|
width: 1em;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
margin: 10px 2px;
|
margin: 0.5em 0.1em;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
animation: loaderAnimation 1s infinite;
|
animation: loaderAnimation 1s infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spin1 {
|
@for $i from 0 to 5 {
|
||||||
animation-delay: 0s;
|
.spin#{$i} {
|
||||||
}
|
animation-delay: 0.1s * $i;
|
||||||
|
}
|
||||||
.spin2 {
|
|
||||||
animation-delay: 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spin3 {
|
|
||||||
animation-delay: 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spin4 {
|
|
||||||
animation-delay: 0.3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.spin5 {
|
|
||||||
animation-delay: 0.4s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
33
packages/app/components/ui/pseudoAvatar.scss
Normal file
33
packages/app/components/ui/pseudoAvatar.scss
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
@import '~app/components/ui/colors.scss';
|
||||||
|
|
||||||
|
.pseudoAvatar {
|
||||||
|
composes: minecraft-character from '~app/components/ui/icons.scss';
|
||||||
|
|
||||||
|
&0 {
|
||||||
|
color: $green;
|
||||||
|
}
|
||||||
|
|
||||||
|
&1 {
|
||||||
|
color: $blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
&2 {
|
||||||
|
color: $violet;
|
||||||
|
}
|
||||||
|
|
||||||
|
&3 {
|
||||||
|
color: $orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
&4 {
|
||||||
|
color: $dark_blue;
|
||||||
|
}
|
||||||
|
|
||||||
|
&5 {
|
||||||
|
color: $light_violet;
|
||||||
|
}
|
||||||
|
|
||||||
|
&6 {
|
||||||
|
color: $red;
|
||||||
|
}
|
||||||
|
}
|
106
packages/app/components/userbar/AccountSwitcher.tsx
Normal file
106
packages/app/components/userbar/AccountSwitcher.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import React, { ComponentType, MouseEventHandler, useCallback } from 'react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { FormattedMessage as Message } from 'react-intl';
|
||||||
|
|
||||||
|
import { PseudoAvatar } from 'app/components/ui';
|
||||||
|
import { Button } from 'app/components/ui/form';
|
||||||
|
import { Account } from 'app/components/accounts/reducer';
|
||||||
|
import messages from 'app/components/accounts/accountSwitcher.intl';
|
||||||
|
|
||||||
|
import styles from './accountSwitcher.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
activeAccount: Account;
|
||||||
|
accounts: ReadonlyArray<Account>;
|
||||||
|
onAccountClick?: (account: Account) => void;
|
||||||
|
onRemoveClick?: (account: Account) => void;
|
||||||
|
onLoginClick?: MouseEventHandler<HTMLAnchorElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AccountSwitcher: ComponentType<Props> = ({
|
||||||
|
activeAccount,
|
||||||
|
accounts,
|
||||||
|
onAccountClick = () => {},
|
||||||
|
onRemoveClick = () => {},
|
||||||
|
onLoginClick,
|
||||||
|
}) => {
|
||||||
|
const available = accounts.filter((account) => account.id !== activeAccount.id);
|
||||||
|
const onAccountClickCallback = useCallback(
|
||||||
|
(account: Account): MouseEventHandler => (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
onAccountClick(account);
|
||||||
|
},
|
||||||
|
[onAccountClick],
|
||||||
|
);
|
||||||
|
const onAccountRemoveCallback = useCallback(
|
||||||
|
(account: Account): MouseEventHandler => (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
onRemoveClick(account);
|
||||||
|
},
|
||||||
|
[onRemoveClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx(styles.accountSwitcher)} data-testid="account-switcher">
|
||||||
|
<div className={styles.item} data-testid="active-account">
|
||||||
|
<PseudoAvatar className={styles.activeAccountIcon} />
|
||||||
|
<div className={styles.activeAccountInfo}>
|
||||||
|
<div className={styles.activeAccountUsername}>{activeAccount.username}</div>
|
||||||
|
<div className={clsx(styles.accountEmail, styles.activeAccountEmail)}>{activeAccount.email}</div>
|
||||||
|
<div className={styles.links}>
|
||||||
|
<div className={styles.link}>
|
||||||
|
<a href={`//ely.by/u${activeAccount.id}`} target="_blank">
|
||||||
|
<Message {...messages.goToEly} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className={styles.link}>
|
||||||
|
<a
|
||||||
|
className={styles.link}
|
||||||
|
data-testid="logout-account"
|
||||||
|
onClick={onAccountRemoveCallback(activeAccount)}
|
||||||
|
href="#"
|
||||||
|
>
|
||||||
|
<Message {...messages.logout} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{available.map((account, index) => (
|
||||||
|
<div
|
||||||
|
className={clsx(styles.item, styles.accountSwitchItem)}
|
||||||
|
key={account.id}
|
||||||
|
data-e2e-account-id={account.id}
|
||||||
|
onClick={onAccountClickCallback(account)}
|
||||||
|
>
|
||||||
|
<PseudoAvatar index={index + 1} className={styles.accountIcon} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={styles.logoutIcon}
|
||||||
|
data-testid="logout-account"
|
||||||
|
onClick={onAccountRemoveCallback(account)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.accountInfo}>
|
||||||
|
<div className={styles.accountUsername}>{account.username}</div>
|
||||||
|
<div className={styles.accountEmail}>{account.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Link to="/login" onClick={onLoginClick}>
|
||||||
|
<Button color="white" data-testid="add-account" block small>
|
||||||
|
<span>
|
||||||
|
<div className={styles.addIcon} />
|
||||||
|
<Message {...messages.addAccount} />
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AccountSwitcher;
|
35
packages/app/components/userbar/LoggedInPanel.story.tsx
Normal file
35
packages/app/components/userbar/LoggedInPanel.story.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { storiesOf } from '@storybook/react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
|
||||||
|
import LoggedInPanel from './LoggedInPanel';
|
||||||
|
|
||||||
|
const activeAccount = {
|
||||||
|
id: 1,
|
||||||
|
username: 'MockUser',
|
||||||
|
email: 'mock@ely.by',
|
||||||
|
refreshToken: '',
|
||||||
|
token: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
storiesOf('Components/Userbar', module)
|
||||||
|
.addDecorator((storyFn) => (
|
||||||
|
<div style={{ background: '#207e5c', paddingRight: '10px', textAlign: 'right' }}>{storyFn()}</div>
|
||||||
|
))
|
||||||
|
.add('LoggedInPanel', () => (
|
||||||
|
<LoggedInPanel
|
||||||
|
activeAccount={activeAccount}
|
||||||
|
accounts={[
|
||||||
|
activeAccount,
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
username: 'AnotherMockUser',
|
||||||
|
email: 'mock-user2@ely.by',
|
||||||
|
token: '',
|
||||||
|
refreshToken: '',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onSwitchAccount={async (account) => action('onSwitchAccount')(account)}
|
||||||
|
onRemoveAccount={async (account) => action('onRemoveAccount')(account)}
|
||||||
|
/>
|
||||||
|
));
|
@ -1,120 +1,64 @@
|
|||||||
import React, { MouseEventHandler } from 'react';
|
import React, { ComponentType, useCallback, useState } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { AccountSwitcher } from 'app/components/accounts';
|
import ClickAwayListener from 'react-click-away-listener';
|
||||||
|
|
||||||
|
import { Account } from 'app/components/accounts';
|
||||||
|
|
||||||
|
import AccountSwitcher from './AccountSwitcher';
|
||||||
|
|
||||||
import styles from './loggedInPanel.scss';
|
import styles from './loggedInPanel.scss';
|
||||||
|
|
||||||
export default class LoggedInPanel extends React.Component<
|
interface Props {
|
||||||
{
|
activeAccount: Account;
|
||||||
username: string;
|
accounts: ReadonlyArray<Account>;
|
||||||
},
|
onSwitchAccount?: (account: Account) => Promise<any>;
|
||||||
{
|
onRemoveAccount?: (account: Account) => Promise<any>;
|
||||||
isAccountSwitcherActive: boolean;
|
}
|
||||||
}
|
|
||||||
> {
|
|
||||||
state = {
|
|
||||||
isAccountSwitcherActive: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
_isMounted: boolean = false;
|
const LoggedInPanel: ComponentType<Props> = ({ activeAccount, accounts, onSwitchAccount, onRemoveAccount }) => {
|
||||||
el: HTMLElement | null;
|
const [isAccountSwitcherActive, setAccountSwitcherState] = useState(false);
|
||||||
|
const hideAccountSwitcher = useCallback(() => setAccountSwitcherState(false), []);
|
||||||
|
const onAccountClick = useCallback(
|
||||||
|
async (account: Account) => {
|
||||||
|
if (onSwitchAccount) {
|
||||||
|
await onSwitchAccount(account);
|
||||||
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
setAccountSwitcherState(false);
|
||||||
if (window.document) {
|
},
|
||||||
// @ts-ignore
|
[onSwitchAccount],
|
||||||
window.document.addEventListener('click', this.onBodyClick);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
this._isMounted = true;
|
return (
|
||||||
}
|
<div className={styles.loggedInPanel}>
|
||||||
|
<div
|
||||||
componentWillUnmount() {
|
className={clsx(styles.activeAccount, {
|
||||||
if (window.document) {
|
[styles.activeAccountExpanded]: isAccountSwitcherActive,
|
||||||
// @ts-ignore
|
})}
|
||||||
window.document.removeEventListener('click', this.onBodyClick);
|
>
|
||||||
}
|
<ClickAwayListener onClickAway={hideAccountSwitcher}>
|
||||||
|
<button
|
||||||
this._isMounted = false;
|
className={styles.activeAccountButton}
|
||||||
}
|
onClick={setAccountSwitcherState.bind(null, !isAccountSwitcherActive)}
|
||||||
|
>
|
||||||
render() {
|
|
||||||
const { username } = this.props;
|
|
||||||
const { isAccountSwitcherActive } = this.state;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={(el) => (this.el = el)} className={clsx(styles.loggedInPanel)}>
|
|
||||||
<div
|
|
||||||
className={clsx(styles.activeAccount, {
|
|
||||||
[styles.activeAccountExpanded]: isAccountSwitcherActive,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<button className={styles.activeAccountButton} onClick={this.onExpandAccountSwitcher}>
|
|
||||||
<span className={styles.userIcon} />
|
<span className={styles.userIcon} />
|
||||||
<span className={styles.userName}>{username}</span>
|
<span className={styles.userName}>{activeAccount.username}</span>
|
||||||
<span className={styles.expandIcon} />
|
<span className={styles.expandIcon} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className={clsx(styles.accountSwitcherContainer)}>
|
<div className={styles.accountSwitcherContainer}>
|
||||||
<AccountSwitcher skin="light" onAfterAction={this.onToggleAccountSwitcher} />
|
<AccountSwitcher
|
||||||
|
activeAccount={activeAccount}
|
||||||
|
accounts={accounts}
|
||||||
|
onAccountClick={onAccountClick}
|
||||||
|
onRemoveClick={onRemoveAccount}
|
||||||
|
onLoginClick={hideAccountSwitcher}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ClickAwayListener>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
|
||||||
|
|
||||||
toggleAccountSwitcher = () =>
|
|
||||||
this._isMounted &&
|
|
||||||
this.setState({
|
|
||||||
isAccountSwitcherActive: !this.state.isAccountSwitcherActive,
|
|
||||||
});
|
|
||||||
|
|
||||||
onToggleAccountSwitcher = () => {
|
|
||||||
this.toggleAccountSwitcher();
|
|
||||||
};
|
|
||||||
|
|
||||||
onExpandAccountSwitcher = (event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
this.toggleAccountSwitcher();
|
|
||||||
};
|
|
||||||
|
|
||||||
onBodyClick = createOnOutsideComponentClickHandler(
|
|
||||||
() => this.el,
|
|
||||||
() => this.state.isAccountSwitcherActive && this._isMounted,
|
|
||||||
() => this.toggleAccountSwitcher(),
|
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
export default LoggedInPanel;
|
||||||
* 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
|
|
||||||
*
|
|
||||||
* @returns {Function}
|
|
||||||
*/
|
|
||||||
function createOnOutsideComponentClickHandler(
|
|
||||||
getEl: () => HTMLElement | null,
|
|
||||||
isActive: () => boolean,
|
|
||||||
callback: () => void,
|
|
||||||
): MouseEventHandler {
|
|
||||||
// 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) => {
|
|
||||||
const el = getEl();
|
|
||||||
|
|
||||||
if (isActive() && el) {
|
|
||||||
if (!el.contains(event.target as HTMLElement) && el !== event.target) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
// add a small delay for the case someone have alredy called toggle
|
|
||||||
setTimeout(() => isActive() && callback(), 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
@ -1,49 +0,0 @@
|
|||||||
import React, { Component } from 'react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { FormattedMessage as Message } from 'react-intl';
|
|
||||||
import { Account } from 'app/components/accounts/reducer';
|
|
||||||
import buttons from 'app/components/ui/buttons.scss';
|
|
||||||
|
|
||||||
import styles from './userbar.scss';
|
|
||||||
import LoggedInPanel from './LoggedInPanel';
|
|
||||||
|
|
||||||
export default class Userbar extends Component<{
|
|
||||||
account: Account | null;
|
|
||||||
guestAction: 'register' | 'login';
|
|
||||||
}> {
|
|
||||||
static displayName = 'Userbar';
|
|
||||||
|
|
||||||
static defaultProps = {
|
|
||||||
guestAction: 'register',
|
|
||||||
};
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const { account, guestAction: actionType } = this.props;
|
|
||||||
|
|
||||||
let guestAction: React.ReactElement;
|
|
||||||
|
|
||||||
switch (actionType) {
|
|
||||||
case 'login':
|
|
||||||
guestAction = (
|
|
||||||
<Link to="/login" className={buttons.blue}>
|
|
||||||
<Message key="login" defaultMessage="Sign in" />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case 'register':
|
|
||||||
default:
|
|
||||||
guestAction = (
|
|
||||||
<Link to="/register" className={buttons.blue}>
|
|
||||||
<Message key="register" defaultMessage="Join" />
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.userbar}>
|
|
||||||
{account ? <LoggedInPanel username={account.username} /> : guestAction}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
126
packages/app/components/userbar/accountSwitcher.scss
Normal file
126
packages/app/components/userbar/accountSwitcher.scss
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
@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;
|
||||||
|
|
||||||
|
background: #fff;
|
||||||
|
color: #444;
|
||||||
|
min-width: 205px;
|
||||||
|
|
||||||
|
$border: 1px solid $lightBorderColor;
|
||||||
|
border-left: $border;
|
||||||
|
border-right: $border;
|
||||||
|
border-bottom: 7px solid darker($green);
|
||||||
|
}
|
||||||
|
|
||||||
|
.accountInfo {
|
||||||
|
}
|
||||||
|
|
||||||
|
.accountUsername,
|
||||||
|
.accountEmail {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
float: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activeAccountIcon {
|
||||||
|
composes: accountIcon;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addIcon {
|
||||||
|
composes: plus from '~app/components/ui/icons.scss';
|
||||||
|
|
||||||
|
color: $green;
|
||||||
|
position: relative;
|
||||||
|
bottom: 1px;
|
||||||
|
margin-right: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoutIcon {
|
||||||
|
composes: exit from '~app/components/ui/icons.scss';
|
||||||
|
|
||||||
|
color: #cdcdcd;
|
||||||
|
float: right;
|
||||||
|
line-height: 27px;
|
||||||
|
transition: 0.25s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #777;
|
||||||
|
}
|
||||||
|
}
|
@ -1,3 +0,0 @@
|
|||||||
.userbar {
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
@ -18,6 +18,7 @@
|
|||||||
"raf": "^3.4.1",
|
"raf": "^3.4.1",
|
||||||
"raven-js": "^3.27.0",
|
"raven-js": "^3.27.0",
|
||||||
"react": "^16.13.1",
|
"react": "^16.13.1",
|
||||||
|
"react-click-away-listener": "^1.4.3",
|
||||||
"react-dom": "^16.13.1",
|
"react-dom": "^16.13.1",
|
||||||
"react-helmet-async": "^1.0.6",
|
"react-helmet-async": "^1.0.6",
|
||||||
"react-intl": "^4.5.7",
|
"react-intl": "^4.5.7",
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { withRouter } from 'react-router-dom';
|
import { Route, Switch } from 'react-router-dom';
|
||||||
import { FormattedMessage as Message } from 'react-intl';
|
|
||||||
import { Route, Link, Switch } from 'react-router-dom';
|
|
||||||
import { Helmet } from 'react-helmet-async';
|
import { Helmet } from 'react-helmet-async';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
@ -10,16 +8,15 @@ import { resetAuth } from 'app/components/auth/actions';
|
|||||||
import { ScrollIntoView } from 'app/components/ui/scroll';
|
import { ScrollIntoView } from 'app/components/ui/scroll';
|
||||||
import PrivateRoute from 'app/containers/PrivateRoute';
|
import PrivateRoute from 'app/containers/PrivateRoute';
|
||||||
import AuthFlowRoute from 'app/containers/AuthFlowRoute';
|
import AuthFlowRoute from 'app/containers/AuthFlowRoute';
|
||||||
import Userbar from 'app/components/userbar/Userbar';
|
|
||||||
import { PopupStack } from 'app/components/ui/popup';
|
import { PopupStack } from 'app/components/ui/popup';
|
||||||
import * as loader from 'app/services/loader';
|
import * as loader from 'app/services/loader';
|
||||||
import { getActiveAccount } from 'app/components/accounts/reducer';
|
import { getActiveAccount } from 'app/components/accounts/reducer';
|
||||||
import { User } from 'app/components/user';
|
import { User } from 'app/components/user';
|
||||||
import { Account } from 'app/components/accounts/reducer';
|
import { Account } from 'app/components/accounts/reducer';
|
||||||
import { ComponentLoader } from 'app/components/ui/loader';
|
import { ComponentLoader } from 'app/components/ui/loader';
|
||||||
|
import Toolbar from './Toolbar';
|
||||||
|
|
||||||
import styles from './root.scss';
|
import styles from './root.scss';
|
||||||
import siteName from './siteName.intl';
|
|
||||||
|
|
||||||
import PageNotFound from 'app/pages/404/PageNotFound';
|
import PageNotFound from 'app/pages/404/PageNotFound';
|
||||||
|
|
||||||
@ -35,9 +32,6 @@ class RootPage extends React.PureComponent<{
|
|||||||
user: User;
|
user: User;
|
||||||
isPopupActive: boolean;
|
isPopupActive: boolean;
|
||||||
onLogoClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
onLogoClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||||
location: {
|
|
||||||
pathname: string;
|
|
||||||
};
|
|
||||||
}> {
|
}> {
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.onPageUpdate();
|
this.onPageUpdate();
|
||||||
@ -52,9 +46,7 @@ class RootPage extends React.PureComponent<{
|
|||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const { props } = this;
|
|
||||||
const { user, account, isPopupActive, onLogoClick } = this.props;
|
const { user, account, isPopupActive, onLogoClick } = this.props;
|
||||||
const isRegisterPage = props.location.pathname === '/register';
|
|
||||||
|
|
||||||
if (document && document.body) {
|
if (document && document.body) {
|
||||||
document.body.style.overflow = isPopupActive ? 'hidden' : '';
|
document.body.style.overflow = isPopupActive ? 'hidden' : '';
|
||||||
@ -74,16 +66,7 @@ class RootPage extends React.PureComponent<{
|
|||||||
[styles.isPopupActive]: isPopupActive,
|
[styles.isPopupActive]: isPopupActive,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className={styles.header} data-testid="toolbar">
|
<Toolbar account={account} onLogoClick={onLogoClick} />
|
||||||
<div className={styles.headerContent}>
|
|
||||||
<Link to="/" className={styles.logo} onClick={onLogoClick} data-testid="home-page">
|
|
||||||
<Message {...siteName} />
|
|
||||||
</Link>
|
|
||||||
<div className={styles.userbar}>
|
|
||||||
<Userbar account={account} guestAction={isRegisterPage ? 'login' : 'register'} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<React.Suspense fallback={<ComponentLoader />}>
|
<React.Suspense fallback={<ComponentLoader />}>
|
||||||
<Switch>
|
<Switch>
|
||||||
@ -111,15 +94,13 @@ class RootPage extends React.PureComponent<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default withRouter(
|
export default connect(
|
||||||
connect(
|
(state) => ({
|
||||||
(state) => ({
|
user: state.user,
|
||||||
user: state.user,
|
account: getActiveAccount(state),
|
||||||
account: getActiveAccount(state),
|
isPopupActive: state.popup.popups.length > 0,
|
||||||
isPopupActive: state.popup.popups.length > 0,
|
}),
|
||||||
}),
|
{
|
||||||
{
|
onLogoClick: resetAuth,
|
||||||
onLogoClick: resetAuth,
|
},
|
||||||
},
|
)(RootPage);
|
||||||
)(RootPage),
|
|
||||||
);
|
|
||||||
|
68
packages/app/pages/root/Toolbar.tsx
Normal file
68
packages/app/pages/root/Toolbar.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import React, { ComponentType, MouseEventHandler, ReactElement, useCallback } from 'react';
|
||||||
|
import { FormattedMessage as Message } from 'react-intl';
|
||||||
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useReduxDispatch, useReduxSelector } from 'app/functions';
|
||||||
|
import { authenticate, revoke } from 'app/components/accounts/actions';
|
||||||
|
import { Account } from 'app/components/accounts/reducer';
|
||||||
|
import buttons from 'app/components/ui/buttons.scss';
|
||||||
|
import LoggedInPanel from 'app/components/userbar/LoggedInPanel';
|
||||||
|
import * as loader from 'app/services/loader';
|
||||||
|
|
||||||
|
import siteName from './siteName.intl';
|
||||||
|
import styles from './root.scss';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
account: Account | null;
|
||||||
|
onLogoClick?: MouseEventHandler<HTMLAnchorElement>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Toolbar: ComponentType<Props> = ({ onLogoClick, account }) => {
|
||||||
|
const dispatch = useReduxDispatch();
|
||||||
|
const location = useLocation();
|
||||||
|
const availableAccounts = useReduxSelector((state) => state.accounts.available);
|
||||||
|
const switchAccount = useCallback((account: Account) => {
|
||||||
|
loader.show();
|
||||||
|
|
||||||
|
return dispatch(authenticate(account)).finally(loader.hide);
|
||||||
|
}, []);
|
||||||
|
const removeAccount = useCallback((account: Account) => dispatch(revoke(account)), []);
|
||||||
|
|
||||||
|
let userBar: ReactElement;
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
userBar = (
|
||||||
|
<LoggedInPanel
|
||||||
|
activeAccount={account}
|
||||||
|
accounts={availableAccounts}
|
||||||
|
onSwitchAccount={switchAccount}
|
||||||
|
onRemoveAccount={removeAccount}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (location.pathname === '/register') {
|
||||||
|
userBar = (
|
||||||
|
<Link to="/login" className={buttons.blue}>
|
||||||
|
<Message key="login" defaultMessage="Sign in" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
userBar = (
|
||||||
|
<Link to="/register" className={buttons.blue}>
|
||||||
|
<Message key="register" defaultMessage="Join" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.toolbar} data-testid="toolbar">
|
||||||
|
<div className={styles.toolbarContent}>
|
||||||
|
<Link to="/" className={styles.siteName} onClick={onLogoClick} data-testid="home-page">
|
||||||
|
<Message {...siteName} />
|
||||||
|
</Link>
|
||||||
|
<div className={styles.userBar}>{userBar}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Toolbar;
|
@ -1,7 +1,7 @@
|
|||||||
@import '~app/components/ui/colors.scss';
|
@import '~app/components/ui/colors.scss';
|
||||||
@import '~app/components/ui/fonts.scss';
|
@import '~app/components/ui/fonts.scss';
|
||||||
|
|
||||||
$userBarHeight: 50px;
|
$toolbarHeight: 50px;
|
||||||
|
|
||||||
.root {
|
.root {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@ -9,11 +9,11 @@ $userBarHeight: 50px;
|
|||||||
|
|
||||||
.viewPort {
|
.viewPort {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
transition: filter 0.4s 0.1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.isPopupActive {
|
.isPopupActive {
|
||||||
filter: blur(5px);
|
filter: blur(5px);
|
||||||
transition: filter 0.4s 0.1s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.wrapper {
|
.wrapper {
|
||||||
@ -21,21 +21,21 @@ $userBarHeight: 50px;
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.toolbar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
height: $userBarHeight;
|
height: $toolbarHeight;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: $green;
|
background: $green;
|
||||||
}
|
}
|
||||||
|
|
||||||
.headerContent {
|
.toolbarContent {
|
||||||
composes: wrapper;
|
composes: wrapper;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.siteName {
|
||||||
line-height: 50px;
|
line-height: 50px;
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@ -44,7 +44,15 @@ $userBarHeight: 50px;
|
|||||||
|
|
||||||
font-family: $font-family-title;
|
font-family: $font-family-title;
|
||||||
font-size: 33px;
|
font-size: 33px;
|
||||||
color: #fff !important;
|
color: #fff!important; // TODO: why?
|
||||||
|
}
|
||||||
|
|
||||||
|
.userBar {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 115px;
|
||||||
|
top: 0;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.body {
|
.body {
|
||||||
@ -55,12 +63,5 @@ $userBarHeight: 50px;
|
|||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
padding-top: $userBarHeight; // place for header
|
padding-top: $toolbarHeight; // space for the toolbar
|
||||||
}
|
|
||||||
|
|
||||||
.userbar {
|
|
||||||
position: absolute;
|
|
||||||
right: 0;
|
|
||||||
left: 115px;
|
|
||||||
top: 0;
|
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
|
import expect from 'app/test/unexpected';
|
||||||
|
import sinon, { SinonMock } from 'sinon';
|
||||||
|
|
||||||
import ChooseAccountState from 'app/services/authFlow/ChooseAccountState';
|
import ChooseAccountState from 'app/services/authFlow/ChooseAccountState';
|
||||||
import CompleteState from 'app/services/authFlow/CompleteState';
|
import CompleteState from 'app/services/authFlow/CompleteState';
|
||||||
import LoginState from 'app/services/authFlow/LoginState';
|
import LoginState from 'app/services/authFlow/LoginState';
|
||||||
import { SinonMock } from 'sinon';
|
|
||||||
|
|
||||||
import { bootstrap, expectState, expectNavigate, expectRun, MockedAuthContext } from './helpers';
|
import { bootstrap, expectState, expectNavigate, expectRun, MockedAuthContext } from './helpers';
|
||||||
|
|
||||||
@ -49,10 +51,18 @@ describe('ChooseAccountState', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('#resolve', () => {
|
describe('#resolve', () => {
|
||||||
it('should transition to complete if existed account was choosen', () => {
|
it('should transition to complete if an existing account was chosen', () => {
|
||||||
|
expectRun(
|
||||||
|
mock,
|
||||||
|
'authenticate',
|
||||||
|
sinon.match({
|
||||||
|
id: 123,
|
||||||
|
}),
|
||||||
|
).returns(Promise.resolve());
|
||||||
|
expectRun(mock, 'setAccountSwitcher', false);
|
||||||
expectState(mock, CompleteState);
|
expectState(mock, CompleteState);
|
||||||
|
|
||||||
state.resolve(context, { id: 123 });
|
return expect(state.resolve(context, { id: 123 }), 'to be fulfilled');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should transition to login if user wants to add new account', () => {
|
it('should transition to login if user wants to add new account', () => {
|
||||||
@ -60,7 +70,8 @@ describe('ChooseAccountState', () => {
|
|||||||
expectRun(mock, 'setLogin', null);
|
expectRun(mock, 'setLogin', null);
|
||||||
expectState(mock, LoginState);
|
expectState(mock, LoginState);
|
||||||
|
|
||||||
state.resolve(context, {});
|
// Assert nothing returned
|
||||||
|
return expect(state.resolve(context, {}), 'to be undefined');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -19,13 +19,16 @@ export default class ChooseAccountState extends AbstractState {
|
|||||||
resolve(context: AuthContext, payload: Account | Record<string, any>): Promise<void> | void {
|
resolve(context: AuthContext, payload: Account | Record<string, any>): Promise<void> | void {
|
||||||
if (payload.id) {
|
if (payload.id) {
|
||||||
// payload is Account
|
// payload is Account
|
||||||
context.setState(new CompleteState());
|
return context
|
||||||
} else {
|
.run('authenticate', payload)
|
||||||
// log in to another account
|
.then(() => context.run('setAccountSwitcher', false))
|
||||||
context.navigate('/login');
|
.then(() => context.setState(new CompleteState()));
|
||||||
context.run('setLogin', null);
|
|
||||||
context.setState(new LoginState());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// log in to another account
|
||||||
|
context.navigate('/login');
|
||||||
|
context.run('setLogin', null);
|
||||||
|
context.setState(new LoginState());
|
||||||
}
|
}
|
||||||
|
|
||||||
reject(context: AuthContext): void {
|
reject(context: AuthContext): void {
|
||||||
|
@ -74,6 +74,7 @@ describe('MfaState', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expectRun(mock, 'setAccountSwitcher', false);
|
||||||
expectRun(
|
expectRun(
|
||||||
mock,
|
mock,
|
||||||
'login',
|
'login',
|
||||||
|
@ -19,15 +19,16 @@ export default class MfaState extends AbstractState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
resolve(context: AuthContext, { totp }: { totp: string }): Promise<void> | void {
|
resolve(context: AuthContext, { totp }: { totp: string }): Promise<void> | void {
|
||||||
const { login, password, rememberMe } = getCredentials(context.getState());
|
const { login, password, rememberMe, isRelogin } = getCredentials(context.getState());
|
||||||
|
|
||||||
return context
|
return context
|
||||||
.run('login', {
|
.run('login', {
|
||||||
totp,
|
|
||||||
password,
|
|
||||||
rememberMe,
|
|
||||||
login,
|
login,
|
||||||
|
password,
|
||||||
|
totp,
|
||||||
|
rememberMe,
|
||||||
})
|
})
|
||||||
|
.then(() => !isRelogin && context.run('setAccountSwitcher', false))
|
||||||
.then(() => context.setState(new CompleteState()))
|
.then(() => context.setState(new CompleteState()))
|
||||||
.catch((err = {}) => err.errors || logger.warn('Error logging in', err));
|
.catch((err = {}) => err.errors || logger.warn('Error logging in', err));
|
||||||
}
|
}
|
||||||
|
@ -69,6 +69,7 @@ describe('PasswordState', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expectRun(mock, 'setAccountSwitcher', false);
|
||||||
expectRun(
|
expectRun(
|
||||||
mock,
|
mock,
|
||||||
'login',
|
'login',
|
||||||
@ -102,6 +103,8 @@ describe('PasswordState', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Should not run "setAccountSwitcher"
|
||||||
|
|
||||||
expectRun(
|
expectRun(
|
||||||
mock,
|
mock,
|
||||||
'login',
|
'login',
|
||||||
@ -136,6 +139,7 @@ describe('PasswordState', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expectRun(mock, 'setAccountSwitcher', false);
|
||||||
expectRun(
|
expectRun(
|
||||||
mock,
|
mock,
|
||||||
'login',
|
'login',
|
||||||
@ -194,6 +198,7 @@ describe('PasswordState', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Should not run "setAccountSwitcher"
|
||||||
expectRun(mock, 'activateAccount', { id: 2 });
|
expectRun(mock, 'activateAccount', { id: 2 });
|
||||||
expectRun(mock, 'removeAccount', { id: 1 });
|
expectRun(mock, 'removeAccount', { id: 1 });
|
||||||
expectState(mock, ChooseAccountState);
|
expectState(mock, ChooseAccountState);
|
||||||
|
@ -33,7 +33,7 @@ export default class PasswordState extends AbstractState {
|
|||||||
rememberMe: boolean;
|
rememberMe: boolean;
|
||||||
},
|
},
|
||||||
): Promise<void> | void {
|
): Promise<void> | void {
|
||||||
const { login, returnUrl } = getCredentials(context.getState());
|
const { login, returnUrl, isRelogin } = getCredentials(context.getState());
|
||||||
|
|
||||||
return context
|
return context
|
||||||
.run('login', {
|
.run('login', {
|
||||||
@ -48,6 +48,10 @@ export default class PasswordState extends AbstractState {
|
|||||||
return context.setState(new MfaState());
|
return context.setState(new MfaState());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isRelogin) {
|
||||||
|
context.run('setAccountSwitcher', false);
|
||||||
|
}
|
||||||
|
|
||||||
if (returnUrl) {
|
if (returnUrl) {
|
||||||
context.navigate(returnUrl);
|
context.navigate(returnUrl);
|
||||||
|
|
||||||
|
@ -13317,6 +13317,11 @@ raw-loader@^4.0.1:
|
|||||||
loader-utils "^2.0.0"
|
loader-utils "^2.0.0"
|
||||||
schema-utils "^2.6.5"
|
schema-utils "^2.6.5"
|
||||||
|
|
||||||
|
react-click-away-listener@^1.4.3:
|
||||||
|
version "1.4.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/react-click-away-listener/-/react-click-away-listener-1.4.3.tgz#a323c9f37784e133b31b2a242367be5675267186"
|
||||||
|
integrity sha512-c7d6mfZuHu/rIdnEHnovX/QsScQXlqtdAynSnZUyyH+6kPOAyB40k2c5br56c/qp4KBkHD0JQV4C7rVuAmroMw==
|
||||||
|
|
||||||
react-clientside-effect@^1.2.2:
|
react-clientside-effect@^1.2.2:
|
||||||
version "1.2.2"
|
version "1.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.2.tgz#6212fb0e07b204e714581dd51992603d1accc837"
|
resolved "https://registry.yarnpkg.com/react-clientside-effect/-/react-clientside-effect-1.2.2.tgz#6212fb0e07b204e714581dd51992603d1accc837"
|
||||||
|
Loading…
Reference in New Issue
Block a user