mirror of
https://github.com/elyby/accounts-frontend.git
synced 2025-02-05 16:40:09 +05:30
Merge branch 'account_deletion' into master
This commit is contained in:
commit
73caa34acc
@ -191,7 +191,7 @@ Storybook:
|
||||
- sentry-cli releases deploys $VERSION new -e $CI_ENVIRONMENT_NAME
|
||||
- sentry-cli releases finalize $VERSION
|
||||
|
||||
Deploy dev:
|
||||
Dev:
|
||||
extends:
|
||||
- .deployJob
|
||||
environment:
|
||||
@ -199,11 +199,15 @@ Deploy dev:
|
||||
variables:
|
||||
VM_HOST_NAME: playground.ely.local
|
||||
VM_DEPLOY_PATH: /srv/dev.account.ely.by/frontend
|
||||
only:
|
||||
refs:
|
||||
- master
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "master"'
|
||||
when: on_success
|
||||
- if: '$CI_COMMIT_MESSAGE =~ /\[deploy dev\]/'
|
||||
when: on_success
|
||||
# Default:
|
||||
- when: never
|
||||
|
||||
Deploy prod:
|
||||
Prod:
|
||||
extends:
|
||||
- .deployJob
|
||||
stage: deploy
|
||||
@ -217,4 +221,5 @@ Deploy prod:
|
||||
when: never
|
||||
- if: '$CI_COMMIT_MESSAGE =~ /\[deploy\]/'
|
||||
when: on_success
|
||||
# Default:
|
||||
- when: manual
|
||||
|
126
@types/chalk.d.ts
vendored
126
@types/chalk.d.ts
vendored
@ -7,30 +7,30 @@ declare module 'chalk' {
|
||||
const enum LevelEnum {
|
||||
/**
|
||||
All colors disabled.
|
||||
*/
|
||||
*/
|
||||
None = 0,
|
||||
|
||||
/**
|
||||
Basic 16 colors support.
|
||||
*/
|
||||
*/
|
||||
Basic = 1,
|
||||
|
||||
/**
|
||||
ANSI 256 colors support.
|
||||
*/
|
||||
*/
|
||||
Ansi256 = 2,
|
||||
|
||||
/**
|
||||
Truecolor 16 million colors support.
|
||||
*/
|
||||
*/
|
||||
TrueColor = 3,
|
||||
}
|
||||
|
||||
/**
|
||||
Basic foreground colors.
|
||||
|
||||
|
||||
[More colors here.](https://github.com/chalk/chalk/blob/master/readme.md#256-and-truecolor-color-support)
|
||||
*/
|
||||
*/
|
||||
type ForegroundColor =
|
||||
| 'black'
|
||||
| 'red'
|
||||
@ -53,9 +53,9 @@ declare module 'chalk' {
|
||||
|
||||
/**
|
||||
Basic background colors.
|
||||
|
||||
|
||||
[More colors here.](https://github.com/chalk/chalk/blob/master/readme.md#256-and-truecolor-color-support)
|
||||
*/
|
||||
*/
|
||||
type BackgroundColor =
|
||||
| 'bgBlack'
|
||||
| 'bgRed'
|
||||
@ -78,9 +78,9 @@ declare module 'chalk' {
|
||||
|
||||
/**
|
||||
Basic colors.
|
||||
|
||||
|
||||
[More colors here.](https://github.com/chalk/chalk/blob/master/readme.md#256-and-truecolor-color-support)
|
||||
*/
|
||||
*/
|
||||
type Color = ForegroundColor | BackgroundColor;
|
||||
|
||||
type Modifiers =
|
||||
@ -101,59 +101,59 @@ declare module 'chalk' {
|
||||
/**
|
||||
Specify the color support for Chalk.
|
||||
By default, color support is automatically detected based on the environment.
|
||||
*/
|
||||
*/
|
||||
level?: Level;
|
||||
}
|
||||
|
||||
interface Instance {
|
||||
/**
|
||||
Return a new Chalk instance.
|
||||
*/
|
||||
*/
|
||||
new (options?: Options): Chalk;
|
||||
}
|
||||
|
||||
/**
|
||||
Detect whether the terminal supports color.
|
||||
*/
|
||||
*/
|
||||
interface ColorSupport {
|
||||
/**
|
||||
The color level used by Chalk.
|
||||
*/
|
||||
*/
|
||||
level: Level;
|
||||
|
||||
/**
|
||||
Return whether Chalk supports basic 16 colors.
|
||||
*/
|
||||
*/
|
||||
hasBasic: boolean;
|
||||
|
||||
/**
|
||||
Return whether Chalk supports ANSI 256 colors.
|
||||
*/
|
||||
*/
|
||||
has256: boolean;
|
||||
|
||||
/**
|
||||
Return whether Chalk supports Truecolor 16 million colors.
|
||||
*/
|
||||
*/
|
||||
has16m: boolean;
|
||||
}
|
||||
|
||||
interface ChalkFunction {
|
||||
/**
|
||||
Use a template string.
|
||||
|
||||
|
||||
@remarks Template literals are unsupported for nested calls (see [issue #341](https://github.com/chalk/chalk/issues/341))
|
||||
|
||||
|
||||
@example
|
||||
```
|
||||
import chalk = require('chalk');
|
||||
|
||||
|
||||
log(chalk`
|
||||
CPU: {red ${cpu.totalPercent}%}
|
||||
RAM: {green ${ram.used / ram.total * 100}%}
|
||||
DISK: {rgb(255,131,0) ${disk.used / disk.total * 100}%}
|
||||
`);
|
||||
```
|
||||
*/
|
||||
*/
|
||||
(text: TemplateStringsArray, ...placeholders: unknown[]): string;
|
||||
|
||||
(...text: unknown[]): string;
|
||||
@ -162,182 +162,182 @@ declare module 'chalk' {
|
||||
interface Chalk extends ChalkFunction {
|
||||
/**
|
||||
Return a new Chalk instance.
|
||||
*/
|
||||
*/
|
||||
Instance: Instance;
|
||||
|
||||
/**
|
||||
The color support for Chalk.
|
||||
By default, color support is automatically detected based on the environment.
|
||||
*/
|
||||
*/
|
||||
level: Level;
|
||||
|
||||
/**
|
||||
Use HEX value to set text color.
|
||||
|
||||
|
||||
@param color - Hexadecimal value representing the desired color.
|
||||
|
||||
|
||||
@example
|
||||
```
|
||||
import chalk = require('chalk');
|
||||
|
||||
|
||||
chalk.hex('#DEADED');
|
||||
```
|
||||
*/
|
||||
*/
|
||||
hex(color: string): Chalk;
|
||||
|
||||
/**
|
||||
Use keyword color value to set text color.
|
||||
|
||||
|
||||
@param color - Keyword value representing the desired color.
|
||||
|
||||
|
||||
@example
|
||||
```
|
||||
import chalk = require('chalk');
|
||||
|
||||
|
||||
chalk.keyword('orange');
|
||||
```
|
||||
*/
|
||||
*/
|
||||
keyword(color: string): Chalk;
|
||||
|
||||
/**
|
||||
Use RGB values to set text color.
|
||||
*/
|
||||
*/
|
||||
rgb(red: number, green: number, blue: number): Chalk;
|
||||
|
||||
/**
|
||||
Use HSL values to set text color.
|
||||
*/
|
||||
*/
|
||||
hsl(hue: number, saturation: number, lightness: number): Chalk;
|
||||
|
||||
/**
|
||||
Use HSV values to set text color.
|
||||
*/
|
||||
*/
|
||||
hsv(hue: number, saturation: number, value: number): Chalk;
|
||||
|
||||
/**
|
||||
Use HWB values to set text color.
|
||||
*/
|
||||
*/
|
||||
hwb(hue: number, whiteness: number, blackness: number): Chalk;
|
||||
|
||||
/**
|
||||
Use a [Select/Set Graphic Rendition](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters) (SGR) [color code number](https://en.wikipedia.org/wiki/ANSI_escape_code#3/4_bit) to set text color.
|
||||
|
||||
|
||||
30 <= code && code < 38 || 90 <= code && code < 98
|
||||
For example, 31 for red, 91 for redBright.
|
||||
*/
|
||||
*/
|
||||
ansi(code: number): Chalk;
|
||||
|
||||
/**
|
||||
Use a [8-bit unsigned number](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) to set text color.
|
||||
*/
|
||||
*/
|
||||
ansi256(index: number): Chalk;
|
||||
|
||||
/**
|
||||
Use HEX value to set background color.
|
||||
|
||||
|
||||
@param color - Hexadecimal value representing the desired color.
|
||||
|
||||
|
||||
@example
|
||||
```
|
||||
import chalk = require('chalk');
|
||||
|
||||
|
||||
chalk.bgHex('#DEADED');
|
||||
```
|
||||
*/
|
||||
*/
|
||||
bgHex(color: string): Chalk;
|
||||
|
||||
/**
|
||||
Use keyword color value to set background color.
|
||||
|
||||
|
||||
@param color - Keyword value representing the desired color.
|
||||
|
||||
|
||||
@example
|
||||
```
|
||||
import chalk = require('chalk');
|
||||
|
||||
|
||||
chalk.bgKeyword('orange');
|
||||
```
|
||||
*/
|
||||
*/
|
||||
bgKeyword(color: string): Chalk;
|
||||
|
||||
/**
|
||||
Use RGB values to set background color.
|
||||
*/
|
||||
*/
|
||||
bgRgb(red: number, green: number, blue: number): Chalk;
|
||||
|
||||
/**
|
||||
Use HSL values to set background color.
|
||||
*/
|
||||
*/
|
||||
bgHsl(hue: number, saturation: number, lightness: number): Chalk;
|
||||
|
||||
/**
|
||||
Use HSV values to set background color.
|
||||
*/
|
||||
*/
|
||||
bgHsv(hue: number, saturation: number, value: number): Chalk;
|
||||
|
||||
/**
|
||||
Use HWB values to set background color.
|
||||
*/
|
||||
*/
|
||||
bgHwb(hue: number, whiteness: number, blackness: number): Chalk;
|
||||
|
||||
/**
|
||||
Use a [Select/Set Graphic Rendition](https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters) (SGR) [color code number](https://en.wikipedia.org/wiki/ANSI_escape_code#3/4_bit) to set background color.
|
||||
|
||||
|
||||
30 <= code && code < 38 || 90 <= code && code < 98
|
||||
For example, 31 for red, 91 for redBright.
|
||||
Use the foreground code, not the background code (for example, not 41, nor 101).
|
||||
*/
|
||||
*/
|
||||
bgAnsi(code: number): Chalk;
|
||||
|
||||
/**
|
||||
Use a [8-bit unsigned number](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) to set background color.
|
||||
*/
|
||||
*/
|
||||
bgAnsi256(index: number): Chalk;
|
||||
|
||||
/**
|
||||
Modifier: Resets the current color chain.
|
||||
*/
|
||||
*/
|
||||
readonly reset: Chalk;
|
||||
|
||||
/**
|
||||
Modifier: Make text bold.
|
||||
*/
|
||||
*/
|
||||
readonly bold: Chalk;
|
||||
|
||||
/**
|
||||
Modifier: Emitting only a small amount of light.
|
||||
*/
|
||||
*/
|
||||
readonly dim: Chalk;
|
||||
|
||||
/**
|
||||
Modifier: Make text italic. (Not widely supported)
|
||||
*/
|
||||
*/
|
||||
readonly italic: Chalk;
|
||||
|
||||
/**
|
||||
Modifier: Make text underline. (Not widely supported)
|
||||
*/
|
||||
*/
|
||||
readonly underline: Chalk;
|
||||
|
||||
/**
|
||||
Modifier: Inverse background and foreground colors.
|
||||
*/
|
||||
*/
|
||||
readonly inverse: Chalk;
|
||||
|
||||
/**
|
||||
Modifier: Prints the text, but makes it invisible.
|
||||
*/
|
||||
*/
|
||||
readonly hidden: Chalk;
|
||||
|
||||
/**
|
||||
Modifier: Puts a horizontal line through the center of the text. (Not widely supported)
|
||||
*/
|
||||
*/
|
||||
readonly strikethrough: Chalk;
|
||||
|
||||
/**
|
||||
Modifier: Prints the text only when Chalk has a color support level > 0.
|
||||
Can be useful for things that are purely cosmetic.
|
||||
*/
|
||||
*/
|
||||
readonly visible: Chalk;
|
||||
|
||||
readonly black: Chalk;
|
||||
@ -403,7 +403,7 @@ declare module 'chalk' {
|
||||
Call the last one as a method with a string argument.
|
||||
Order doesn't matter, and later styles take precedent in case of a conflict.
|
||||
This simply means that `chalk.red.yellow.green` is equivalent to `chalk.green`.
|
||||
*/
|
||||
*/
|
||||
const chalk: chalk.Chalk &
|
||||
chalk.ChalkFunction & {
|
||||
supportsColor: chalk.ColorSupport | false;
|
||||
|
@ -2,16 +2,29 @@
|
||||
// @ts-nocheck
|
||||
module.exports = function (api) {
|
||||
const env = api.env();
|
||||
api.cache(true);
|
||||
const isProduction = api.env((envName) => envName.includes('production'));
|
||||
|
||||
api.cache.using(() => env);
|
||||
|
||||
const browserEnv = {
|
||||
plugins: ['react-hot-loader/babel'],
|
||||
presets: [
|
||||
[
|
||||
'@babel/preset-env',
|
||||
{
|
||||
shippedProposals: true,
|
||||
ignoreBrowserslistConfig: false,
|
||||
modules: false,
|
||||
useBuiltIns: 'usage', // or "entry"
|
||||
corejs: 3,
|
||||
include: ['proposal-class-properties'],
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
return {
|
||||
presets: [
|
||||
[
|
||||
'@babel/preset-typescript',
|
||||
{
|
||||
allowDeclareFields: true,
|
||||
},
|
||||
],
|
||||
'@babel/preset-react',
|
||||
[
|
||||
'@babel/preset-env',
|
||||
@ -23,11 +36,24 @@ module.exports = function (api) {
|
||||
modules: 'commonjs',
|
||||
},
|
||||
],
|
||||
[
|
||||
// NOTE: preset-typescript must go before proposal-class-properties
|
||||
// in order to use allowDeclareFields option
|
||||
// proposal-class-properties is enabled by preset-env for browser env
|
||||
// preset-env for nodejs does not need it, because recent node versions support class fields
|
||||
//
|
||||
// but, due to some bugs (?), we must place preset-typescript here so that it loads as
|
||||
// last default preset, before loading browser presets.
|
||||
// Only this combination is working without errors
|
||||
'@babel/preset-typescript',
|
||||
{
|
||||
allowDeclareFields: true,
|
||||
},
|
||||
],
|
||||
],
|
||||
plugins: [
|
||||
'@babel/plugin-syntax-dynamic-import',
|
||||
'@babel/plugin-proposal-function-bind',
|
||||
'@babel/plugin-proposal-class-properties',
|
||||
'@babel/plugin-proposal-optional-chaining',
|
||||
'@babel/plugin-transform-runtime',
|
||||
[
|
||||
@ -36,25 +62,14 @@ module.exports = function (api) {
|
||||
removePrefix: 'packages.app',
|
||||
messagesDir: './build/messages/',
|
||||
useKey: true,
|
||||
removeDefaultMessage: env === 'production',
|
||||
removeDefaultMessage: isProduction,
|
||||
},
|
||||
],
|
||||
],
|
||||
env: {
|
||||
webpack: {
|
||||
plugins: ['react-hot-loader/babel'],
|
||||
presets: [
|
||||
[
|
||||
'@babel/preset-env',
|
||||
{
|
||||
ignoreBrowserslistConfig: false,
|
||||
modules: false,
|
||||
useBuiltIns: 'usage', // or "entry"
|
||||
corejs: 3,
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
browser: browserEnv,
|
||||
'browser-development': browserEnv,
|
||||
'browser-production': browserEnv,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
@ -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,10 +7,11 @@ import * as authentication from 'app/services/api/authentication';
|
||||
import { authenticate, revoke, logoutAll, logoutStrangers } from 'app/components/accounts/actions';
|
||||
import { add, activate, remove, reset } from 'app/components/accounts/actions/pure-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 { Account } from './reducer';
|
||||
import { User } from 'app/components/user';
|
||||
|
||||
jest.mock('app/i18n', () => ({
|
||||
en: {
|
||||
@ -32,19 +33,21 @@ jest.mock('app/i18n', () => ({
|
||||
const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJlbHl8MSJ9.pRJ7vakt2eIscjqwG__KhSxKb3qwGsdBBeDbBffJs_I';
|
||||
const legacyToken = 'eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOjF9.cRF-sQNrwWQ94xCb3vWioVdjxAZeefEE7GMGwh7708o';
|
||||
|
||||
const account = {
|
||||
const account: Account = {
|
||||
id: 1,
|
||||
username: 'username',
|
||||
email: 'email@test.com',
|
||||
token,
|
||||
refreshToken: 'bar',
|
||||
isDeleted: false,
|
||||
};
|
||||
|
||||
const user = {
|
||||
const user: Partial<User> = {
|
||||
id: 1,
|
||||
username: 'username',
|
||||
email: 'email@test.com',
|
||||
lang: 'be',
|
||||
isDeleted: false,
|
||||
};
|
||||
|
||||
describe('components/accounts/actions', () => {
|
||||
@ -182,11 +185,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', () => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { getJwtPayloads } from 'app/functions';
|
||||
import { sessionStorage } from 'app/services/localStorage';
|
||||
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 { setLocale } from 'app/components/i18n/actions';
|
||||
import logger from 'app/services/logger';
|
||||
@ -12,13 +12,6 @@ import { add, remove, activate, reset, updateToken } from './actions/pure-action
|
||||
|
||||
export { updateToken, activate, remove };
|
||||
|
||||
/**
|
||||
* @param {Account|object} account
|
||||
* @param {string} account.token
|
||||
* @param {string} account.refreshToken
|
||||
*
|
||||
* @returns {Function}
|
||||
*/
|
||||
export function authenticate(
|
||||
account:
|
||||
| Account
|
||||
@ -53,13 +46,13 @@ export function authenticate(
|
||||
token,
|
||||
refreshToken,
|
||||
);
|
||||
const { auth } = getState();
|
||||
const newAccount: Account = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
token: newToken,
|
||||
refreshToken: newRefreshToken,
|
||||
isDeleted: user.isDeleted,
|
||||
};
|
||||
dispatch(add(newAccount));
|
||||
dispatch(activate(newAccount));
|
||||
@ -78,14 +71,6 @@ export function authenticate(
|
||||
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;
|
||||
|
@ -59,4 +59,16 @@ export function updateToken(token: string): UpdateTokenAction {
|
||||
};
|
||||
}
|
||||
|
||||
export type Action = AddAction | RemoveAction | ActivateAction | ResetAction | UpdateTokenAction;
|
||||
interface MarkAsDeletedAction extends ReduxAction {
|
||||
type: 'accounts:markAsDeleted';
|
||||
payload: boolean;
|
||||
}
|
||||
|
||||
export function markAsDeleted(isDeleted: boolean): MarkAsDeletedAction {
|
||||
return {
|
||||
type: 'accounts:markAsDeleted',
|
||||
payload: isDeleted,
|
||||
};
|
||||
}
|
||||
|
||||
export type Action = AddAction | RemoveAction | ActivateAction | ResetAction | UpdateTokenAction | MarkAsDeletedAction;
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { State, Account as AccountType } from './reducer';
|
||||
|
||||
export { default as AccountSwitcher } from './AccountSwitcher';
|
||||
export type AccountsState = State;
|
||||
export type Account = AccountType;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import expect from 'app/test/unexpected';
|
||||
|
||||
import { updateToken } from './actions';
|
||||
import { add, remove, activate, reset } from './actions/pure-actions';
|
||||
import { add, remove, activate, reset, markAsDeleted } from './actions/pure-actions';
|
||||
import { AccountsState } from './index';
|
||||
import accounts, { Account } from './reducer';
|
||||
|
||||
@ -10,7 +10,9 @@ const account: Account = {
|
||||
username: 'username',
|
||||
email: 'email@test.com',
|
||||
token: 'foo',
|
||||
} as Account;
|
||||
refreshToken: '',
|
||||
isDeleted: false,
|
||||
};
|
||||
|
||||
describe('Accounts reducer', () => {
|
||||
let initial: AccountsState;
|
||||
@ -124,4 +126,20 @@ describe('Accounts reducer', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('accounts:markAsDeleted', () => {
|
||||
it('should mark account as deleted', () => {
|
||||
const isDeleted = true;
|
||||
|
||||
expect(accounts({ active: account.id, available: [account] }, markAsDeleted(isDeleted)), 'to satisfy', {
|
||||
active: account.id,
|
||||
available: [
|
||||
{
|
||||
...account,
|
||||
isDeleted,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -6,6 +6,7 @@ export type Account = {
|
||||
email: string;
|
||||
token: string;
|
||||
refreshToken: string | null;
|
||||
isDeleted: boolean;
|
||||
};
|
||||
|
||||
export type State = {
|
||||
@ -23,6 +24,23 @@ export function getAvailableAccounts(state: { accounts: State }): Array<Account>
|
||||
return state.accounts.available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move deleted accounts to the end of the accounts list.
|
||||
*/
|
||||
export function getSortedAccounts(state: { accounts: State }): ReadonlyArray<Account> {
|
||||
return state.accounts.available.sort((acc1, acc2) => {
|
||||
if (acc1.isDeleted && !acc2.isDeleted) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (!acc1.isDeleted && acc2.isDeleted) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
export default function accounts(
|
||||
state: State = {
|
||||
active: null,
|
||||
@ -90,27 +108,33 @@ export default function accounts(
|
||||
}
|
||||
|
||||
case 'accounts:updateToken': {
|
||||
if (typeof action.payload !== 'string') {
|
||||
throw new Error('payload must be a jwt token');
|
||||
}
|
||||
return partiallyUpdateActiveAccount(state, {
|
||||
token: action.payload,
|
||||
});
|
||||
}
|
||||
|
||||
const { payload } = action;
|
||||
|
||||
return {
|
||||
...state,
|
||||
available: state.available.map((account) => {
|
||||
if (account.id === state.active) {
|
||||
return {
|
||||
...account,
|
||||
token: payload,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...account };
|
||||
}),
|
||||
};
|
||||
case 'accounts:markAsDeleted': {
|
||||
return partiallyUpdateActiveAccount(state, {
|
||||
isDeleted: action.payload,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
function partiallyUpdateActiveAccount(state: State, payload: Partial<Account>): State {
|
||||
return {
|
||||
...state,
|
||||
available: state.available.map((account) => {
|
||||
if (account.id === state.active) {
|
||||
return {
|
||||
...account,
|
||||
...payload,
|
||||
};
|
||||
}
|
||||
|
||||
return { ...account };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
30
packages/app/components/auth/Auth.story.tsx
Normal file
30
packages/app/components/auth/Auth.story.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import React, { ComponentType } from 'react';
|
||||
import { useHistory, useLocation, useRouteMatch } from 'react-router';
|
||||
|
||||
import { Factory } from './factory';
|
||||
import PanelTransition from './PanelTransition';
|
||||
|
||||
interface AuthPresenterProps {
|
||||
factory: Factory;
|
||||
}
|
||||
|
||||
export const AuthPresenter: ComponentType<AuthPresenterProps> = ({ factory }) => {
|
||||
const { Title, Body, Footer, Links } = factory();
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const match = useRouteMatch();
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '340px', padding: '55px 50px', textAlign: 'center' }}>
|
||||
<PanelTransition
|
||||
Title={<Title />}
|
||||
Body={<Body history={history} location={location} match={match} />}
|
||||
Footer={<Footer />}
|
||||
Links={<Links />}
|
||||
// TODO: inject actions, when PanelTransition become a pure component
|
||||
// resolve={action('resolve')}
|
||||
// reject={action('reject')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -15,7 +15,7 @@ class BaseAuthBody extends React.Component<
|
||||
RouteComponentProps<Record<string, any>>
|
||||
> {
|
||||
static contextType = Context;
|
||||
/* TODO: use declare */ context: React.ContextType<typeof Context>;
|
||||
declare context: React.ContextType<typeof Context>;
|
||||
prevErrors: AuthContext['auth']['error'];
|
||||
|
||||
autoFocusField: string | null = '';
|
||||
|
@ -8,8 +8,8 @@ export interface AuthContext {
|
||||
user: User;
|
||||
requestRedraw: () => Promise<void>;
|
||||
clearErrors: () => void;
|
||||
resolve: (payload: { [key: string]: any } | undefined) => void;
|
||||
reject: (payload: { [key: string]: any } | undefined) => void;
|
||||
resolve: (payload: Record<string, any> | undefined) => Promise<any> | void;
|
||||
reject: (payload: Record<string, any> | undefined) => Promise<any> | void;
|
||||
}
|
||||
|
||||
const Context = React.createContext<AuthContext>({
|
||||
|
@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
|
||||
import { AuthPresenter } from 'app/components/auth/Auth.story';
|
||||
|
||||
import AcceptRules from './AcceptRules';
|
||||
|
||||
storiesOf('Components/Auth', module).add('AcceptRules', () => <AuthPresenter factory={AcceptRules} />);
|
@ -7,6 +7,7 @@ import Body from './AcceptRulesBody';
|
||||
const messages = defineMessages({
|
||||
title: 'User Agreement',
|
||||
declineAndLogout: 'Decline and logout',
|
||||
deleteAccount: 'Delete account',
|
||||
});
|
||||
|
||||
export default factory({
|
||||
@ -17,7 +18,13 @@ export default factory({
|
||||
autoFocus: true,
|
||||
children: <Message key="accept" defaultMessage="Accept" />,
|
||||
},
|
||||
links: {
|
||||
label: messages.declineAndLogout,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
label: messages.declineAndLogout,
|
||||
},
|
||||
{
|
||||
label: messages.deleteAccount,
|
||||
payload: { deleteAccount: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
|
||||
import { AuthPresenter } from 'app/components/auth/Auth.story';
|
||||
|
||||
import Activation from './Activation';
|
||||
|
||||
// TODO: add case with provided key
|
||||
storiesOf('Components/Auth', module).add('Activation', () => <AuthPresenter factory={Activation} />);
|
@ -0,0 +1,61 @@
|
||||
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,
|
||||
[styles.deletedAccount]: account.isDeleted,
|
||||
})}
|
||||
key={account.id}
|
||||
data-e2e-account-id={account.id}
|
||||
onClick={onAccountClickCallback(account)}
|
||||
>
|
||||
<PseudoAvatar index={index} deleted={account.isDeleted} className={styles.accountAvatar} />
|
||||
<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;
|
@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
|
||||
import { AuthPresenter } from 'app/components/auth/Auth.story';
|
||||
|
||||
import ChooseAccount from './ChooseAccount';
|
||||
|
||||
// TODO: provide accounts list
|
||||
// TODO: provide application name
|
||||
storiesOf('Components/Auth', module).add('ChooseAccount', () => <AuthPresenter factory={ChooseAccount} />);
|
@ -2,12 +2,23 @@ import React from 'react';
|
||||
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
|
||||
import { connect } from 'app/functions';
|
||||
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
|
||||
import { AccountSwitcher } from 'app/components/accounts';
|
||||
import { Account } from 'app/components/accounts/reducer';
|
||||
import { getSortedAccounts } from 'app/components/accounts/reducer';
|
||||
import type { Account } from 'app/components/accounts';
|
||||
|
||||
import AccountSwitcher from './AccountSwitcher';
|
||||
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: getSortedAccounts(state),
|
||||
}))(AccountSwitcher);
|
||||
|
||||
export default class ChooseAccountBody extends BaseAuthBody {
|
||||
static displayName = 'ChooseAccountBody';
|
||||
static panelId = 'chooseAccount';
|
||||
@ -29,28 +40,21 @@ export default class ChooseAccountBody extends BaseAuthBody {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.description}>
|
||||
<Message
|
||||
key="pleaseChooseAccount"
|
||||
defaultMessage="Please select an account you're willing to use"
|
||||
/>
|
||||
</div>
|
||||
<Message
|
||||
key="pleaseChooseAccount"
|
||||
defaultMessage="Please select an account you're willing to use"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.accountSwitcherContainer}>
|
||||
<AccountSwitcher
|
||||
allowAdd={false}
|
||||
allowLogout={false}
|
||||
highlightActiveAccount={false}
|
||||
onSwitch={this.onSwitch}
|
||||
/>
|
||||
<ConnectedAccountSwitcher onAccountClick={this.onSwitch} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
onSwitch = (account: Account): void => {
|
||||
this.context.resolve(account);
|
||||
onSwitch = (account: Account): Promise<void> => {
|
||||
return Promise.resolve(this.context.resolve(account));
|
||||
};
|
||||
}
|
||||
|
@ -0,0 +1,95 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
.accountAvatar {
|
||||
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;
|
||||
|
||||
.deletedAccount & {
|
||||
color: #aaa;
|
||||
}
|
||||
}
|
||||
|
||||
.accountEmail {
|
||||
@extend %overflowText;
|
||||
color: #999;
|
||||
font-size: 12px;
|
||||
|
||||
.deletedAccount & {
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
@ -28,7 +28,7 @@ const FooterMenu: ComponentType = () => {
|
||||
<Message key="rules" defaultMessage="Rules" />
|
||||
</Link>
|
||||
|
||||
{'ꞏ'}
|
||||
{'•'}
|
||||
|
||||
<ContactLink className={styles.footerItem}>
|
||||
<Message key="contactUs" defaultMessage="Contact Us" />
|
||||
@ -40,7 +40,7 @@ const FooterMenu: ComponentType = () => {
|
||||
<Message key="forDevelopers" defaultMessage="For developers" />
|
||||
</Link>
|
||||
|
||||
{'ꞏ'}
|
||||
{'•'}
|
||||
|
||||
<a href="#" className={styles.footerItem} onClick={createPopupHandler(SourceCode)}>
|
||||
<Message key="sourceCode" defaultMessage="Source code" />
|
||||
|
@ -41,5 +41,6 @@ export function getLocaleIconUrl(locale: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
return require('./flags/unknown.svg').default;
|
||||
}
|
||||
|
20
packages/app/components/profile/AccountDeleted.story.tsx
Normal file
20
packages/app/components/profile/AccountDeleted.story.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { ProfileLayout } from 'app/components/profile/Profile.story';
|
||||
|
||||
import AccountDeleted from './AccountDeleted';
|
||||
|
||||
storiesOf('Components/Profile', module).add('AccountDeleted', () => (
|
||||
<ProfileLayout>
|
||||
<AccountDeleted
|
||||
onRestore={() =>
|
||||
new Promise((resolve) => {
|
||||
action('onRestore')();
|
||||
setTimeout(resolve, 500);
|
||||
})
|
||||
}
|
||||
/>
|
||||
</ProfileLayout>
|
||||
));
|
56
packages/app/components/profile/AccountDeleted.tsx
Normal file
56
packages/app/components/profile/AccountDeleted.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import React, { ComponentType, useCallback, useState } from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
|
||||
import { Button } from 'app/components/ui/form';
|
||||
|
||||
import styles from './accountDeleted.scss';
|
||||
|
||||
interface Props {
|
||||
onRestore?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AccountDeleted: ComponentType<Props> = ({ onRestore }) => {
|
||||
const [isSubmitted, setIsSubmitted] = useState<boolean>(false);
|
||||
const onRestoreClick = useCallback(() => {
|
||||
if (!onRestore) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitted(true);
|
||||
onRestore().finally(() => setIsSubmitted(false));
|
||||
}, [onRestore]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrapper} data-testid="deletedAccount">
|
||||
<Message key="accountDeleted" defaultMessage="Account is deleted">
|
||||
{(pageTitle: string) => (
|
||||
<h2 className={styles.title}>
|
||||
<Helmet title={pageTitle} />
|
||||
{pageTitle}
|
||||
</h2>
|
||||
)}
|
||||
</Message>
|
||||
|
||||
<div className={styles.description}>
|
||||
<Message
|
||||
key="accountDeletedDescription"
|
||||
defaultMessage="The account has been marked for deletion and will be permanently removed within a week. Until then, all account activities have been suspended."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.description}>
|
||||
<Message
|
||||
key="ifYouWantToRestoreAccount"
|
||||
defaultMessage="If you want to restore your account, click on the button below."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button onClick={onRestoreClick} color="black" small loading={isSubmitted}>
|
||||
<Message key="restoreAccount" defaultMessage="Restore account" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountDeleted;
|
@ -22,6 +22,7 @@ storiesOf('Components/Profile', module).add('Profile', () => (
|
||||
hasMojangUsernameCollision: true,
|
||||
isActive: true,
|
||||
isGuest: false,
|
||||
isDeleted: false,
|
||||
isOtpEnabled: true,
|
||||
lang: 'unknown',
|
||||
passwordChangedAt: 1595328712,
|
||||
|
@ -2,8 +2,10 @@ import React, { ComponentType, useCallback, useRef } from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
|
||||
import { ChangeLanguageLink } from 'app/components/languageSwitcher';
|
||||
import { RelativeTime } from 'app/components/ui';
|
||||
import { Button } from 'app/components/ui/form';
|
||||
import { User } from 'app/components/user';
|
||||
import RulesPage from 'app/pages/rules/RulesPage';
|
||||
|
||||
@ -61,7 +63,7 @@ const Profile: ComponentType<Props> = ({ user, activeLocale }) => {
|
||||
</div>
|
||||
|
||||
<div className={styles.formColumn}>
|
||||
<div className={profileForm.form}>
|
||||
<div className={styles.profilePanel}>
|
||||
<div className={styles.item}>
|
||||
<h3 className={profileForm.title}>
|
||||
<Message key="personalData" defaultMessage="Personal data" />
|
||||
@ -108,20 +110,6 @@ const Profile: ComponentType<Props> = ({ user, activeLocale }) => {
|
||||
value={user.email}
|
||||
/>
|
||||
|
||||
<ProfileField
|
||||
link="/profile/change-password"
|
||||
label={<Message key="password" defaultMessage="Password:" />}
|
||||
value={
|
||||
<Message
|
||||
key="changedAt"
|
||||
defaultMessage="Changed {at}"
|
||||
values={{
|
||||
at: <RelativeTime timestamp={user.passwordChangedAt * 1000} />,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<ProfileField
|
||||
label={<Message key="siteLanguage" defaultMessage="Site language:" />}
|
||||
value={<ChangeLanguageLink />}
|
||||
@ -150,18 +138,6 @@ const Profile: ComponentType<Props> = ({ user, activeLocale }) => {
|
||||
}
|
||||
/>
|
||||
|
||||
<ProfileField
|
||||
link="/profile/mfa"
|
||||
label={<Message key="twoFactorAuth" defaultMessage="Two‑factor auth:" />}
|
||||
value={
|
||||
user.isOtpEnabled ? (
|
||||
<Message key="enabled" defaultMessage="Enabled" />
|
||||
) : (
|
||||
<Message key="disabled" defaultMessage="Disabled" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ProfileField
|
||||
label={<Message key="uuid" defaultMessage="UUID:" />}
|
||||
value={
|
||||
@ -175,6 +151,61 @@ const Profile: ComponentType<Props> = ({ user, activeLocale }) => {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.profilePanel}>
|
||||
<div className={styles.item}>
|
||||
<h3 className={profileForm.title}>
|
||||
<Message key="accountManagement" defaultMessage="Account management" />
|
||||
</h3>
|
||||
<p className={profileForm.description}>
|
||||
<Message
|
||||
key="accountManagementDescription"
|
||||
defaultMessage="In this area you can manage the security settings of your account. Some operations may cause logout on other devices."
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ProfileField
|
||||
link="/profile/change-password"
|
||||
label={<Message key="password" defaultMessage="Password:" />}
|
||||
value={
|
||||
<Message
|
||||
key="changedAt"
|
||||
defaultMessage="Changed {at}"
|
||||
values={{
|
||||
at: <RelativeTime timestamp={user.passwordChangedAt * 1000} />,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<ProfileField
|
||||
link="/profile/mfa"
|
||||
label={<Message key="twoFactorAuth" defaultMessage="Two‑factor auth:" />}
|
||||
value={
|
||||
user.isOtpEnabled ? (
|
||||
<Message key="enabled" defaultMessage="Enabled" />
|
||||
) : (
|
||||
<Message key="disabled" defaultMessage="Disabled" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
<ProfileField
|
||||
value={
|
||||
<Button
|
||||
component={Link}
|
||||
// @ts-ignore
|
||||
to="/profile/delete"
|
||||
small
|
||||
color="black"
|
||||
data-testid="profile-action"
|
||||
>
|
||||
<Message key="accountDeletion" defaultMessage="Account deletion" />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -10,7 +10,7 @@ function ProfileField({
|
||||
link,
|
||||
onChange,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
label?: React.ReactNode;
|
||||
link?: string;
|
||||
onChange?: () => void;
|
||||
value: React.ReactNode;
|
||||
@ -29,7 +29,7 @@ function ProfileField({
|
||||
return (
|
||||
<div className={styles.paramItem} data-testid="profile-item">
|
||||
<div className={styles.paramRow}>
|
||||
<div className={styles.paramName}>{label}</div>
|
||||
{label ? <div className={styles.paramName}>{label}</div> : ''}
|
||||
<div className={styles.paramValue}>{value}</div>
|
||||
|
||||
{Action && (
|
||||
|
24
packages/app/components/profile/accountDeleted.scss
Normal file
24
packages/app/components/profile/accountDeleted.scss
Normal file
@ -0,0 +1,24 @@
|
||||
.wrapper {
|
||||
text-align: center;
|
||||
|
||||
@media (min-height: 600px) {
|
||||
margin-top: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
composes: indexTitle from '~app/components/profile/profile.scss';
|
||||
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.description {
|
||||
composes: indexDescription from '~app/components/profile/profile.scss';
|
||||
|
||||
margin: 0 auto 20px auto;
|
||||
max-width: 330px;
|
||||
|
||||
&:last-of-type {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { storiesOf } from '@storybook/react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
|
||||
import { ProfileLayout } from 'app/components/profile/Profile.story';
|
||||
|
||||
import DeleteAccount from './DeleteAccount';
|
||||
|
||||
storiesOf('Components/Profile', module).add('DeleteAccount', () => (
|
||||
<ProfileLayout>
|
||||
<DeleteAccount
|
||||
onSubmit={async () => {
|
||||
action('onSubmit')();
|
||||
}}
|
||||
/>
|
||||
</ProfileLayout>
|
||||
));
|
163
packages/app/components/profile/deleteAccount/DeleteAccount.tsx
Normal file
163
packages/app/components/profile/deleteAccount/DeleteAccount.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
import React, { ComponentType } from 'react';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
|
||||
import { Button, Form } from 'app/components/ui/form';
|
||||
|
||||
import siteName from 'app/pages/root/siteName.intl';
|
||||
import appName from 'app/components/auth/appInfo/appName.intl';
|
||||
|
||||
import { BackButton } from '../ProfileForm';
|
||||
import styles from '../profileForm.scss';
|
||||
import ownStyles from './deleteAccount.scss';
|
||||
|
||||
interface Props {
|
||||
onSubmit?: () => Promise<void>;
|
||||
}
|
||||
|
||||
const DeleteAccount: ComponentType<Props> = ({ onSubmit }) => (
|
||||
<Form onSubmit={onSubmit}>
|
||||
<div className={styles.contentWithBackButton}>
|
||||
<BackButton />
|
||||
|
||||
<div className={styles.form}>
|
||||
<div className={styles.formBody}>
|
||||
<Message key="accountDeletionTitle" defaultMessage="Account deletion">
|
||||
{(pageTitle) => (
|
||||
<h3 className={styles.title}>
|
||||
<Helmet title={pageTitle as string} />
|
||||
{pageTitle}
|
||||
</h3>
|
||||
)}
|
||||
</Message>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message
|
||||
key="accountDeletionDescription1"
|
||||
defaultMessage="You are about to delete your Ely.by account, which provides access to various Ely.by services. You won't be able to use them and all your account data will be lost."
|
||||
/>
|
||||
</p>
|
||||
<p className={styles.description}>
|
||||
<Message
|
||||
key="accountDeletionDescription2"
|
||||
defaultMessage="You may also lose access to game servers and some third-party services if the Ely.by's authorization service was used to enter them."
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.sectionTitle}>
|
||||
<Message key="dataToBeDeleted" defaultMessage="Data to be deleted:" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={ownStyles.removableDataRow}>
|
||||
<div className={ownStyles.serviceName}>
|
||||
{/* TODO: missing colon */}
|
||||
<Message {...siteName} />
|
||||
</div>
|
||||
<div className={ownStyles.serviceContents}>
|
||||
<ul>
|
||||
<li>
|
||||
<Message key="posts" defaultMessage="Posts" tagName="span" />
|
||||
</li>
|
||||
<li>
|
||||
<Message key="friends" defaultMessage="Friends" tagName="span" />
|
||||
</li>
|
||||
<li>
|
||||
<Message key="directMessages" defaultMessage="Direct messages" tagName="span" />
|
||||
</li>
|
||||
<li>
|
||||
<Message key="cubes" defaultMessage="Cubes" tagName="span" />
|
||||
</li>
|
||||
<li>
|
||||
<Message
|
||||
key="serversForTheSSS"
|
||||
defaultMessage="Servers for the server skins system"
|
||||
tagName="span"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.delimiter} />
|
||||
|
||||
<div className={ownStyles.removableDataRow}>
|
||||
<div className={ownStyles.serviceName}>
|
||||
{/* TODO: missing colon */}
|
||||
<Message {...appName} />
|
||||
</div>
|
||||
<div className={ownStyles.serviceContents}>
|
||||
<ul>
|
||||
<li>
|
||||
<Message key="usernameHistory" defaultMessage="Username history" tagName="span" />
|
||||
</li>
|
||||
<li>
|
||||
<Message key="oauthApps" defaultMessage="OAuth 2.0 applications" tagName="span" />
|
||||
</li>
|
||||
<li>
|
||||
<Message key="minecraftServers" defaultMessage="Minecraft servers" tagName="span" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.delimiter} />
|
||||
|
||||
<div className={ownStyles.removableDataRow}>
|
||||
<div className={ownStyles.serviceName}>Chrly:</div>
|
||||
<div className={ownStyles.serviceContents}>
|
||||
<ul>
|
||||
<li>
|
||||
<Message key="texturesData" defaultMessage="Textures data" tagName="span" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.delimiter} />
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.sectionTitle}>
|
||||
<Message key="dataToBeImpersonalized" defaultMessage="Data to be impersonalized:" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={ownStyles.removableDataRow}>
|
||||
<div className={ownStyles.serviceName}>
|
||||
{/* TODO: missing colon */}
|
||||
<Message {...siteName} />
|
||||
</div>
|
||||
<div className={ownStyles.serviceContents}>
|
||||
<ul>
|
||||
<li>
|
||||
<Message key="uploadedSkins" defaultMessage="Uploaded skins" tagName="span" />
|
||||
</li>
|
||||
<li>
|
||||
<Message key="comments" defaultMessage="Comments" tagName="span" />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formRow}>
|
||||
<p className={styles.description}>
|
||||
<Message
|
||||
key="dataWontBeErasedImmediately"
|
||||
defaultMessage="Data won't be erased immediately after account deletion. Within a week you'll be able to restore your account without losing any data."
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" color="red" block>
|
||||
<Message key="deleteAccount" defaultMessage="Delete account" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
|
||||
export default DeleteAccount;
|
@ -0,0 +1,35 @@
|
||||
@import '~app/components/ui/fonts.scss';
|
||||
|
||||
.removableDataRow {
|
||||
composes: formRow from '~app/components/profile/profileForm.scss';
|
||||
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.serviceName {
|
||||
font-family: $font-family-title;
|
||||
width: 50%;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.serviceContents {
|
||||
width: 50%;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
|
||||
li {
|
||||
position: relative;
|
||||
padding-left: 11px;
|
||||
|
||||
&:before {
|
||||
content: '—';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 2px;
|
||||
}
|
||||
|
||||
span {
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
}
|
1
packages/app/components/profile/deleteAccount/index.ts
Normal file
1
packages/app/components/profile/deleteAccount/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './DeleteAccount';
|
@ -17,7 +17,7 @@ export default class MfaDisable extends React.Component<
|
||||
}
|
||||
> {
|
||||
static contextType = Context;
|
||||
/* TODO: use declare */ context: React.ContextType<typeof Context>;
|
||||
declare context: React.ContextType<typeof Context>;
|
||||
|
||||
state = {
|
||||
showForm: false,
|
||||
|
@ -41,7 +41,7 @@ interface State {
|
||||
|
||||
export default class MfaEnable extends React.PureComponent<Props, State> {
|
||||
static contextType = Context;
|
||||
/* TODO: use declare */ context: React.ContextType<typeof Context>;
|
||||
declare context: React.ContextType<typeof Context>;
|
||||
|
||||
static defaultProps = {
|
||||
confirmationForm: new FormModel(),
|
||||
|
@ -18,7 +18,7 @@ const MfaStatus: ComponentType<Props> = ({ onProceed }) => (
|
||||
<div className={mfaStyles.bigIcon}>
|
||||
<span className={icons.lock} />
|
||||
</div>
|
||||
<p className={`${styles.description} ${mfaStyles.mfaTitle}`}>
|
||||
<p className={mfaStyles.mfaTitle}>
|
||||
<Message
|
||||
key="mfaEnabledForYourAcc"
|
||||
defaultMessage="Two‑factor authentication for your account is active now"
|
||||
|
@ -49,7 +49,7 @@ storiesOf('Components/Profile/MultiFactorAuth', module)
|
||||
))
|
||||
.add('Enabled', () => (
|
||||
<MultiFactorAuth
|
||||
isMfaEnabled={true}
|
||||
isMfaEnabled
|
||||
step={0}
|
||||
onSubmit={(form, sendData) => {
|
||||
action('onSubmit')(form, sendData);
|
||||
|
@ -2,9 +2,8 @@
|
||||
@import '~app/components/ui/fonts.scss';
|
||||
|
||||
.mfaTitle {
|
||||
font-size: 18px;
|
||||
font-family: $font-family-title;
|
||||
line-height: 1.2;
|
||||
composes: sectionTitle from '~app/components/profile/profileForm.scss';
|
||||
|
||||
text-align: center;
|
||||
|
||||
margin-left: 17%;
|
||||
|
@ -10,7 +10,6 @@ $formColumnWidth: 416px;
|
||||
.formColumn {
|
||||
width: $formColumnWidth;
|
||||
min-width: $formColumnWidth; // Чтобы flex не ужимал блок, несмотря на фикс ширину выше
|
||||
border-bottom: 10px solid #ddd8ce;
|
||||
}
|
||||
|
||||
.descriptionColumn {
|
||||
@ -30,6 +29,13 @@ $formColumnWidth: 416px;
|
||||
color: #9a9a9a;
|
||||
}
|
||||
|
||||
.profilePanel {
|
||||
composes: form from '~app/components/profile/profileForm.scss';
|
||||
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 10px solid #ddd8ce;
|
||||
}
|
||||
|
||||
.item {
|
||||
padding: 30px;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
@ -84,6 +84,20 @@
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-family: $font-family-title;
|
||||
font-size: 18px;
|
||||
line-height: 1.2;
|
||||
color: #444;
|
||||
margin-top: 25px;
|
||||
}
|
||||
|
||||
.delimiter {
|
||||
background: #eee;
|
||||
height: 1px;
|
||||
margin: 25px -30px;
|
||||
}
|
||||
|
||||
.stepper {
|
||||
width: 35%;
|
||||
margin: 0 auto;
|
||||
|
27
packages/app/components/ui/PseudoAvatar.tsx
Normal file
27
packages/app/components/ui/PseudoAvatar.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React, { ComponentType } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import styles from './pseudoAvatar.scss';
|
||||
|
||||
interface Props {
|
||||
index?: number;
|
||||
deleted?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const PseudoAvatar: ComponentType<Props> = ({ index = 0, deleted, className }) => (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.pseudoAvatarWrapper,
|
||||
{
|
||||
[styles.deletedPseudoAvatar]: deleted,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={clsx(styles.pseudoAvatar, styles[`pseudoAvatar${index % 7}`])} />
|
||||
{deleted ? <div className={styles.deletedIcon} /> : ''}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default PseudoAvatar;
|
@ -1,7 +1,8 @@
|
||||
import React, { InputHTMLAttributes, MouseEventHandler } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { MessageDescriptor } from 'react-intl';
|
||||
import ClickAwayListener from 'react-click-away-listener';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import { COLOR_GREEN, Color } from 'app/components/ui';
|
||||
|
||||
import styles from './dropdown.scss';
|
||||
@ -12,7 +13,7 @@ type ItemLabel = I18nString | React.ReactElement;
|
||||
|
||||
interface Props extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label: I18nString;
|
||||
items: { [value: string]: ItemLabel };
|
||||
items: Record<string, ItemLabel>;
|
||||
block?: boolean;
|
||||
color: Color;
|
||||
}
|
||||
@ -37,18 +38,6 @@ export default class Dropdown extends FormInputComponent<Props, State> {
|
||||
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() {
|
||||
const { color, block, items, ...restProps } = this.props;
|
||||
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);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ClickAwayListener onClickAway={this.onCloseClick}>
|
||||
<div
|
||||
className={clsx(styles[color], {
|
||||
[styles.block]: block,
|
||||
@ -84,7 +73,7 @@ export default class Dropdown extends FormInputComponent<Props, State> {
|
||||
</div>
|
||||
|
||||
{this.renderError()}
|
||||
</div>
|
||||
</ClickAwayListener>
|
||||
);
|
||||
}
|
||||
|
||||
@ -137,17 +126,9 @@ export default class Dropdown extends FormInputComponent<Props, State> {
|
||||
this.toggle();
|
||||
};
|
||||
|
||||
onBodyClick: MouseEventHandler = (event) => {
|
||||
onCloseClick = () => {
|
||||
if (this.state.isActive) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const el = ReactDOM.findDOMNode(this)!;
|
||||
|
||||
if (!el.contains(event.target as HTMLElement) && el !== event.target) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
this.toggle();
|
||||
}
|
||||
this.toggle();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -30,3 +30,4 @@ export const SKIN_LIGHT: Skin = 'light';
|
||||
export const skins: Array<Skin> = [SKIN_DARK, SKIN_LIGHT];
|
||||
|
||||
export { default as RelativeTime } from './RelativeTime';
|
||||
export { default as PseudoAvatar } from './PseudoAvatar';
|
||||
|
@ -9,10 +9,11 @@ import styles from './componentLoader.scss';
|
||||
|
||||
interface Props {
|
||||
skin?: Skin;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ComponentLoader: ComponentType<Props> = ({ skin = 'dark' }) => (
|
||||
<div className={clsx(styles.componentLoader, styles[`${skin}ComponentLoader`])}>
|
||||
const ComponentLoader: ComponentType<Props> = ({ skin = 'dark', className }) => (
|
||||
<div className={clsx(styles.componentLoader, styles[`${skin}ComponentLoader`], className)}>
|
||||
<div className={styles.spins}>
|
||||
{new Array(5).fill(0).map((_, index) => (
|
||||
<div className={clsx(styles.spin, styles[`spin${index}`])} key={index} />
|
||||
|
@ -1,41 +1,31 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
|
||||
.componentLoader {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.spins {
|
||||
height: 40px;
|
||||
height: 2em;
|
||||
display: flex;
|
||||
flex-shrink: 1;
|
||||
flex-basis: 0;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.spin {
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
display: inline-block;
|
||||
margin: 10px 2px;
|
||||
margin: 0.5em 0.1em;
|
||||
opacity: 0;
|
||||
animation: loaderAnimation 1s infinite;
|
||||
}
|
||||
|
||||
.spin1 {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
|
||||
.spin2 {
|
||||
animation-delay: 0.1s;
|
||||
}
|
||||
|
||||
.spin3 {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.spin4 {
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
.spin5 {
|
||||
animation-delay: 0.4s;
|
||||
@for $i from 0 to 5 {
|
||||
.spin#{$i} {
|
||||
animation-delay: 0.1s * $i;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
53
packages/app/components/ui/pseudoAvatar.scss
Normal file
53
packages/app/components/ui/pseudoAvatar.scss
Normal file
@ -0,0 +1,53 @@
|
||||
@import '~app/components/ui/colors.scss';
|
||||
|
||||
.pseudoAvatarWrapper {
|
||||
position: relative;
|
||||
display: inline-flex; // Needed to get right position of the cross icon
|
||||
}
|
||||
|
||||
.pseudoAvatar {
|
||||
composes: minecraft-character from '~app/components/ui/icons.scss';
|
||||
font-size: 1em;
|
||||
|
||||
&0 {
|
||||
color: $green;
|
||||
}
|
||||
|
||||
&1 {
|
||||
color: $blue;
|
||||
}
|
||||
|
||||
&2 {
|
||||
color: $violet;
|
||||
}
|
||||
|
||||
&3 {
|
||||
color: $orange;
|
||||
}
|
||||
|
||||
&4 {
|
||||
color: $dark_blue;
|
||||
}
|
||||
|
||||
&5 {
|
||||
color: $light_violet;
|
||||
}
|
||||
|
||||
&6 {
|
||||
color: $red;
|
||||
}
|
||||
|
||||
.deletedPseudoAvatar & {
|
||||
color: #ccc;
|
||||
}
|
||||
}
|
||||
|
||||
.deletedIcon {
|
||||
composes: close from '~app/components/ui/icons.scss';
|
||||
|
||||
position: absolute;
|
||||
top: 0.16em;
|
||||
left: -0.145em;
|
||||
font-size: 0.7em;
|
||||
color: rgba($red, 0.75);
|
||||
}
|
@ -10,6 +10,7 @@ export interface User {
|
||||
lang: string;
|
||||
isGuest: boolean;
|
||||
isActive: boolean;
|
||||
isDeleted: boolean;
|
||||
isOtpEnabled: boolean;
|
||||
passwordChangedAt: number;
|
||||
hasMojangUsernameCollision: boolean;
|
||||
@ -31,6 +32,7 @@ const defaults: State = {
|
||||
avatar: '',
|
||||
lang: '',
|
||||
isActive: false,
|
||||
isDeleted: false,
|
||||
isOtpEnabled: false,
|
||||
shouldAcceptRules: false, // whether user need to review updated rules
|
||||
passwordChangedAt: 0,
|
||||
|
108
packages/app/components/userbar/AccountSwitcher.tsx
Normal file
108
packages/app/components/userbar/AccountSwitcher.tsx
Normal file
@ -0,0 +1,108 @@
|
||||
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, {
|
||||
[styles.deletedAccountItem]: account.isDeleted,
|
||||
})}
|
||||
key={account.id}
|
||||
data-e2e-account-id={account.id}
|
||||
onClick={onAccountClickCallback(account)}
|
||||
>
|
||||
<PseudoAvatar index={index + 1} deleted={account.isDeleted} 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;
|
45
packages/app/components/userbar/LoggedInPanel.story.tsx
Normal file
45
packages/app/components/userbar/LoggedInPanel.story.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
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: '',
|
||||
isDeleted: false,
|
||||
};
|
||||
|
||||
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: '',
|
||||
isDeleted: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
username: 'DeletedUser',
|
||||
email: 'i-am-deleted@ely.by',
|
||||
token: '',
|
||||
refreshToken: '',
|
||||
isDeleted: true,
|
||||
},
|
||||
]}
|
||||
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 { 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';
|
||||
|
||||
export default class LoggedInPanel extends React.Component<
|
||||
{
|
||||
username: string;
|
||||
},
|
||||
{
|
||||
isAccountSwitcherActive: boolean;
|
||||
}
|
||||
> {
|
||||
state = {
|
||||
isAccountSwitcherActive: false,
|
||||
};
|
||||
interface Props {
|
||||
activeAccount: Account;
|
||||
accounts: ReadonlyArray<Account>;
|
||||
onSwitchAccount?: (account: Account) => Promise<any>;
|
||||
onRemoveAccount?: (account: Account) => Promise<any>;
|
||||
}
|
||||
|
||||
_isMounted: boolean = false;
|
||||
el: HTMLElement | null;
|
||||
const LoggedInPanel: ComponentType<Props> = ({ activeAccount, accounts, onSwitchAccount, onRemoveAccount }) => {
|
||||
const [isAccountSwitcherActive, setAccountSwitcherState] = useState(false);
|
||||
const hideAccountSwitcher = useCallback(() => setAccountSwitcherState(false), []);
|
||||
const onAccountClick = useCallback(
|
||||
async (account: Account) => {
|
||||
if (onSwitchAccount) {
|
||||
await onSwitchAccount(account);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (window.document) {
|
||||
// @ts-ignore
|
||||
window.document.addEventListener('click', this.onBodyClick);
|
||||
}
|
||||
setAccountSwitcherState(false);
|
||||
},
|
||||
[onSwitchAccount],
|
||||
);
|
||||
|
||||
this._isMounted = true;
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (window.document) {
|
||||
// @ts-ignore
|
||||
window.document.removeEventListener('click', this.onBodyClick);
|
||||
}
|
||||
|
||||
this._isMounted = false;
|
||||
}
|
||||
|
||||
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}>
|
||||
return (
|
||||
<div className={styles.loggedInPanel}>
|
||||
<div
|
||||
className={clsx(styles.activeAccount, {
|
||||
[styles.activeAccountExpanded]: isAccountSwitcherActive,
|
||||
})}
|
||||
>
|
||||
<ClickAwayListener onClickAway={hideAccountSwitcher}>
|
||||
<button
|
||||
className={styles.activeAccountButton}
|
||||
onClick={setAccountSwitcherState.bind(null, !isAccountSwitcherActive)}
|
||||
>
|
||||
<span className={styles.userIcon} />
|
||||
<span className={styles.userName}>{username}</span>
|
||||
<span className={styles.userName}>{activeAccount.username}</span>
|
||||
<span className={styles.expandIcon} />
|
||||
</button>
|
||||
|
||||
<div className={clsx(styles.accountSwitcherContainer)}>
|
||||
<AccountSwitcher skin="light" onAfterAction={this.onToggleAccountSwitcher} />
|
||||
<div className={styles.accountSwitcherContainer}>
|
||||
<AccountSwitcher
|
||||
activeAccount={activeAccount}
|
||||
accounts={accounts}
|
||||
onAccountClick={onAccountClick}
|
||||
onRemoveClick={onRemoveAccount}
|
||||
onLoginClick={hideAccountSwitcher}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ClickAwayListener>
|
||||
</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(),
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
export default LoggedInPanel;
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
134
packages/app/components/userbar/accountSwitcher.scss
Normal file
134
packages/app/components/userbar/accountSwitcher.scss
Normal file
@ -0,0 +1,134 @@
|
||||
@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;
|
||||
|
||||
.deletedAccountItem & {
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.accountEmail {
|
||||
font-size: 10px;
|
||||
color: #999;
|
||||
|
||||
.deletedAccountItem & {
|
||||
color: #a9a9a9;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
/**
|
||||
* Returns the content to be displayed on first render
|
||||
*/
|
||||
|
@ -18,6 +18,7 @@
|
||||
"raf": "^3.4.1",
|
||||
"raven-js": "^3.27.0",
|
||||
"react": "^16.13.1",
|
||||
"react-click-away-listener": "^1.4.3",
|
||||
"react-dom": "^16.13.1",
|
||||
"react-helmet-async": "^1.0.6",
|
||||
"react-intl": "^4.5.7",
|
||||
|
28
packages/app/pages/profile/AccountDeletedPage.tsx
Normal file
28
packages/app/pages/profile/AccountDeletedPage.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import React, { ComponentType, useCallback, useContext } from 'react';
|
||||
|
||||
import { useReduxDispatch } from 'app/functions';
|
||||
import { restoreAccount } from 'app/services/api/accounts';
|
||||
import { updateUser } from 'app/components/user/actions';
|
||||
import { markAsDeleted } from 'app/components/accounts/actions/pure-actions';
|
||||
import ProfileContext from 'app/components/profile/Context';
|
||||
|
||||
import AccountDeleted from 'app/components/profile/AccountDeleted';
|
||||
|
||||
const AccountDeletedPage: ComponentType = () => {
|
||||
const dispatch = useReduxDispatch();
|
||||
const context = useContext(ProfileContext);
|
||||
const onRestore = useCallback(async () => {
|
||||
await restoreAccount(context.userId);
|
||||
dispatch(
|
||||
updateUser({
|
||||
isDeleted: false,
|
||||
}),
|
||||
);
|
||||
dispatch(markAsDeleted(false));
|
||||
context.goToProfile();
|
||||
}, [dispatch, context]);
|
||||
|
||||
return <AccountDeleted onRestore={onRestore} />;
|
||||
};
|
||||
|
||||
export default AccountDeletedPage;
|
@ -18,7 +18,7 @@ interface Props extends RouteComponentProps<RouteParams> {
|
||||
|
||||
class ChangeEmailPage extends React.Component<Props> {
|
||||
static contextType = Context;
|
||||
/* TODO: use declare */ context: React.ContextType<typeof Context>;
|
||||
declare context: React.ContextType<typeof Context>;
|
||||
|
||||
render() {
|
||||
const { step = 'step1', code } = this.props.match.params;
|
||||
|
@ -14,7 +14,7 @@ interface Props {
|
||||
|
||||
class ChangePasswordPage extends React.Component<Props> {
|
||||
static contextType = Context;
|
||||
/* TODO: use declare */ context: React.ContextType<typeof Context>;
|
||||
declare context: React.ContextType<typeof Context>;
|
||||
|
||||
form = new FormModel();
|
||||
|
||||
|
@ -14,7 +14,7 @@ type Props = {
|
||||
|
||||
class ChangeUsernamePage extends React.Component<Props> {
|
||||
static contextType = Context;
|
||||
/* TODO: use declare */ context: React.ContextType<typeof Context>;
|
||||
declare context: React.ContextType<typeof Context>;
|
||||
|
||||
form = new FormModel();
|
||||
|
||||
|
32
packages/app/pages/profile/DeleteAccountPage.tsx
Normal file
32
packages/app/pages/profile/DeleteAccountPage.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React, { ComponentType, useCallback, useContext, useRef } from 'react';
|
||||
|
||||
import { useReduxDispatch } from 'app/functions';
|
||||
import { deleteAccount } from 'app/services/api/accounts';
|
||||
import { FormModel } from 'app/components/ui/form';
|
||||
import DeleteAccount from 'app/components/profile/deleteAccount';
|
||||
import { updateUser } from 'app/components/user/actions';
|
||||
import { markAsDeleted } from 'app/components/accounts/actions/pure-actions';
|
||||
import ProfileContext from 'app/components/profile/Context';
|
||||
|
||||
const DeleteAccountPage: ComponentType = () => {
|
||||
const context = useContext(ProfileContext);
|
||||
const dispatch = useReduxDispatch();
|
||||
const { current: form } = useRef(new FormModel());
|
||||
const onSubmit = useCallback(async () => {
|
||||
await context.onSubmit({
|
||||
form,
|
||||
sendData: () => deleteAccount(context.userId, form.serialize()),
|
||||
});
|
||||
dispatch(
|
||||
updateUser({
|
||||
isDeleted: true,
|
||||
}),
|
||||
);
|
||||
dispatch(markAsDeleted(true));
|
||||
context.goToProfile();
|
||||
}, [context]);
|
||||
|
||||
return <DeleteAccount onSubmit={onSubmit} />;
|
||||
};
|
||||
|
||||
export default DeleteAccountPage;
|
@ -16,7 +16,7 @@ interface Props
|
||||
|
||||
class MultiFactorAuthPage extends React.Component<Props> {
|
||||
static contextType = Context;
|
||||
/* TODO: use declare */ context: React.ContextType<typeof Context>;
|
||||
declare context: React.ContextType<typeof Context>;
|
||||
|
||||
render() {
|
||||
const {
|
||||
|
@ -11,7 +11,7 @@ import { browserHistory } from 'app/services/history';
|
||||
import { FooterMenu } from 'app/components/footerMenu';
|
||||
import { FormModel } from 'app/components/ui/form';
|
||||
import { Provider } from 'app/components/profile/Context';
|
||||
import { ComponentLoader } from 'app/components/ui/loader';
|
||||
import { User } from 'app/components/user';
|
||||
|
||||
import styles from './profile.scss';
|
||||
|
||||
@ -20,14 +20,16 @@ import ChangePasswordPage from 'app/pages/profile/ChangePasswordPage';
|
||||
import ChangeUsernamePage from 'app/pages/profile/ChangeUsernamePage';
|
||||
import ChangeEmailPage from 'app/pages/profile/ChangeEmailPage';
|
||||
import MultiFactorAuthPage from 'app/pages/profile/MultiFactorAuthPage';
|
||||
import DeleteAccountPage from 'app/pages/profile/DeleteAccountPage';
|
||||
import AccountDeletedPage from 'app/pages/profile/AccountDeletedPage';
|
||||
|
||||
interface Props {
|
||||
userId: number;
|
||||
user: User;
|
||||
onSubmit: (options: { form: FormModel; sendData: () => Promise<any> }) => Promise<void>;
|
||||
refreshUserData: () => Promise<any>;
|
||||
}
|
||||
|
||||
const ProfileController: ComponentType<Props> = ({ userId, onSubmit, refreshUserData }) => {
|
||||
const ProfileController: ComponentType<Props> = ({ user, onSubmit, refreshUserData }) => {
|
||||
const goToProfile = useCallback(async () => {
|
||||
await refreshUserData();
|
||||
|
||||
@ -38,23 +40,29 @@ const ProfileController: ComponentType<Props> = ({ userId, onSubmit, refreshUser
|
||||
<div className={styles.container}>
|
||||
<Provider
|
||||
value={{
|
||||
userId,
|
||||
userId: user.id!,
|
||||
onSubmit,
|
||||
goToProfile,
|
||||
}}
|
||||
>
|
||||
<React.Suspense fallback={<ComponentLoader />}>
|
||||
{user.isDeleted ? (
|
||||
<Switch>
|
||||
<Route path="/" exact component={AccountDeletedPage} />
|
||||
<Redirect to="/" />
|
||||
</Switch>
|
||||
) : (
|
||||
<Switch>
|
||||
<Route path="/profile/mfa/step:step([1-3])" component={MultiFactorAuthPage} />
|
||||
<Route path="/profile/mfa" exact component={MultiFactorAuthPage} />
|
||||
<Route path="/profile/change-password" exact component={ChangePasswordPage} />
|
||||
<Route path="/profile/change-username" exact component={ChangeUsernamePage} />
|
||||
<Route path="/profile/change-email/:step?/:code?" component={ChangeEmailPage} />
|
||||
<Route path="/profile/delete" component={DeleteAccountPage} />
|
||||
<Route path="/profile" exact component={Profile} />
|
||||
<Route path="/" exact component={Profile} />
|
||||
<Redirect to="/404" />
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
||||
<div className={styles.footer}>
|
||||
<FooterMenu />
|
||||
@ -66,7 +74,7 @@ const ProfileController: ComponentType<Props> = ({ userId, onSubmit, refreshUser
|
||||
|
||||
export default connect(
|
||||
(state) => ({
|
||||
userId: state.user.id!,
|
||||
user: state.user,
|
||||
}),
|
||||
{
|
||||
refreshUserData,
|
||||
|
@ -1,5 +1,5 @@
|
||||
.container {
|
||||
padding: 55px 10px 65px; // 65px for footer
|
||||
padding: 55px 10px 80px; // 80px for footer
|
||||
}
|
||||
|
||||
.footer {
|
||||
|
@ -1,7 +1,5 @@
|
||||
import React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { FormattedMessage as Message } from 'react-intl';
|
||||
import { Route, Link, Switch } from 'react-router-dom';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import { Helmet } from 'react-helmet-async';
|
||||
import clsx from 'clsx';
|
||||
|
||||
@ -10,21 +8,20 @@ import { resetAuth } from 'app/components/auth/actions';
|
||||
import { ScrollIntoView } from 'app/components/ui/scroll';
|
||||
import PrivateRoute from 'app/containers/PrivateRoute';
|
||||
import AuthFlowRoute from 'app/containers/AuthFlowRoute';
|
||||
import Userbar from 'app/components/userbar/Userbar';
|
||||
import { PopupStack } from 'app/components/ui/popup';
|
||||
import * as loader from 'app/services/loader';
|
||||
import { getActiveAccount } from 'app/components/accounts/reducer';
|
||||
import { User } from 'app/components/user';
|
||||
import { Account } from 'app/components/accounts/reducer';
|
||||
import { ComponentLoader } from 'app/components/ui/loader';
|
||||
import Toolbar from './Toolbar';
|
||||
|
||||
import styles from './root.scss';
|
||||
import siteName from './siteName.intl';
|
||||
|
||||
import PageNotFound from 'app/pages/404/PageNotFound';
|
||||
|
||||
const ProfileController = React.lazy(() =>
|
||||
import(/* webpackChunkName: "page-profile-all" */ 'app/pages/profile/ProfileController'),
|
||||
const ProfileController = React.lazy(
|
||||
() => import(/* webpackChunkName: "page-profile-all" */ 'app/pages/profile/ProfileController'),
|
||||
);
|
||||
const RulesPage = React.lazy(() => import(/* webpackChunkName: "page-rules" */ 'app/pages/rules/RulesPage'));
|
||||
const DevPage = React.lazy(() => import(/* webpackChunkName: "page-dev-applications" */ 'app/pages/dev/DevPage'));
|
||||
@ -35,9 +32,6 @@ class RootPage extends React.PureComponent<{
|
||||
user: User;
|
||||
isPopupActive: boolean;
|
||||
onLogoClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
|
||||
location: {
|
||||
pathname: string;
|
||||
};
|
||||
}> {
|
||||
componentDidMount() {
|
||||
this.onPageUpdate();
|
||||
@ -52,9 +46,7 @@ class RootPage extends React.PureComponent<{
|
||||
}
|
||||
|
||||
render() {
|
||||
const { props } = this;
|
||||
const { user, account, isPopupActive, onLogoClick } = this.props;
|
||||
const isRegisterPage = props.location.pathname === '/register';
|
||||
|
||||
if (document && document.body) {
|
||||
document.body.style.overflow = isPopupActive ? 'hidden' : '';
|
||||
@ -74,16 +66,7 @@ class RootPage extends React.PureComponent<{
|
||||
[styles.isPopupActive]: isPopupActive,
|
||||
})}
|
||||
>
|
||||
<div className={styles.header} data-testid="toolbar">
|
||||
<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>
|
||||
<Toolbar account={account} onLogoClick={onLogoClick} />
|
||||
<div className={styles.body}>
|
||||
<React.Suspense fallback={<ComponentLoader />}>
|
||||
<Switch>
|
||||
@ -111,15 +94,13 @@ class RootPage extends React.PureComponent<{
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(
|
||||
connect(
|
||||
(state) => ({
|
||||
user: state.user,
|
||||
account: getActiveAccount(state),
|
||||
isPopupActive: state.popup.popups.length > 0,
|
||||
}),
|
||||
{
|
||||
onLogoClick: resetAuth,
|
||||
},
|
||||
)(RootPage),
|
||||
);
|
||||
export default connect(
|
||||
(state) => ({
|
||||
user: state.user,
|
||||
account: getActiveAccount(state),
|
||||
isPopupActive: state.popup.popups.length > 0,
|
||||
}),
|
||||
{
|
||||
onLogoClick: resetAuth,
|
||||
},
|
||||
)(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, getSortedAccounts } 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(getSortedAccounts);
|
||||
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/fonts.scss';
|
||||
|
||||
$userBarHeight: 50px;
|
||||
$toolbarHeight: 50px;
|
||||
|
||||
.root {
|
||||
height: 100%;
|
||||
@ -9,11 +9,11 @@ $userBarHeight: 50px;
|
||||
|
||||
.viewPort {
|
||||
height: 100%;
|
||||
transition: filter 0.4s 0.1s ease;
|
||||
}
|
||||
|
||||
.isPopupActive {
|
||||
filter: blur(5px);
|
||||
transition: filter 0.4s 0.1s ease;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
@ -21,21 +21,21 @@ $userBarHeight: 50px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header {
|
||||
.toolbar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
height: $userBarHeight;
|
||||
height: $toolbarHeight;
|
||||
width: 100%;
|
||||
background: $green;
|
||||
}
|
||||
|
||||
.headerContent {
|
||||
.toolbarContent {
|
||||
composes: wrapper;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.logo {
|
||||
.siteName {
|
||||
line-height: 50px;
|
||||
padding: 0 20px;
|
||||
display: inline-block;
|
||||
@ -44,7 +44,15 @@ $userBarHeight: 50px;
|
||||
|
||||
font-family: $font-family-title;
|
||||
font-size: 33px;
|
||||
color: #fff !important;
|
||||
color: #fff!important; // TODO: why?
|
||||
}
|
||||
|
||||
.userBar {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 115px;
|
||||
top: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.body {
|
||||
@ -55,12 +63,5 @@ $userBarHeight: 50px;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
|
||||
padding-top: $userBarHeight; // place for header
|
||||
}
|
||||
|
||||
.userbar {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 115px;
|
||||
top: 0;
|
||||
padding-top: $toolbarHeight; // space for the toolbar
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ export interface UserResponse {
|
||||
id: number;
|
||||
isActive: boolean;
|
||||
isOtpEnabled: boolean;
|
||||
isDeleted: boolean;
|
||||
lang: string;
|
||||
passwordChangedAt: number; // timestamp
|
||||
registeredAt: number; // timestamp
|
||||
@ -16,13 +17,7 @@ export interface UserResponse {
|
||||
}
|
||||
|
||||
export function getInfo(id: number, token?: string): Promise<UserResponse> {
|
||||
return request.get(
|
||||
`/api/v1/accounts/${id}`,
|
||||
{},
|
||||
{
|
||||
token,
|
||||
},
|
||||
);
|
||||
return request.get(`/api/v1/accounts/${id}`, {}, { token });
|
||||
}
|
||||
|
||||
export function changePassword(
|
||||
@ -86,3 +81,13 @@ export function confirmNewEmail(id: number, key: string): Promise<{ success: boo
|
||||
key,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteAccount(id: number, { password }: { password?: string }): Promise<{ success: boolean }> {
|
||||
return request.delete(`/api/v1/accounts/${id}`, {
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
export function restoreAccount(id: number): Promise<{ success: boolean }> {
|
||||
return request.post(`/api/v1/accounts/${id}/restore`);
|
||||
}
|
||||
|
@ -47,6 +47,20 @@ describe('AcceptRulesState', () => {
|
||||
|
||||
state.enter(context);
|
||||
});
|
||||
|
||||
it('should transition to complete state if account is deleted even if user should accept rules', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
shouldAcceptRules: true,
|
||||
isGuest: false,
|
||||
isDeleted: true,
|
||||
},
|
||||
});
|
||||
|
||||
expectState(mock, CompleteState);
|
||||
|
||||
state.enter(context);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#resolve', () => {
|
||||
@ -83,7 +97,13 @@ describe('AcceptRulesState', () => {
|
||||
it('should logout', () => {
|
||||
expectRun(mock, 'logout');
|
||||
|
||||
state.reject(context);
|
||||
state.reject(context, {});
|
||||
});
|
||||
|
||||
it('should navigate to the account deletion page', () => {
|
||||
expectNavigate(mock, '/profile/delete');
|
||||
|
||||
state.reject(context, { deleteAccount: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -8,7 +8,7 @@ export default class AcceptRulesState extends AbstractState {
|
||||
enter(context: AuthContext): Promise<void> | void {
|
||||
const { user } = context.getState();
|
||||
|
||||
if (user.shouldAcceptRules) {
|
||||
if (!user.isDeleted && user.shouldAcceptRules) {
|
||||
context.navigate('/accept-rules');
|
||||
} else {
|
||||
context.setState(new CompleteState());
|
||||
@ -22,7 +22,13 @@ export default class AcceptRulesState extends AbstractState {
|
||||
.catch((err = {}) => err.errors || logger.warn('Error accepting rules', err));
|
||||
}
|
||||
|
||||
reject(context: AuthContext): void {
|
||||
reject(context: AuthContext, payload: Record<string, any>): void {
|
||||
if (payload.deleteAccount) {
|
||||
context.navigate('/profile/delete');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
context.run('logout');
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import expect from 'app/test/unexpected';
|
||||
import sinon, { SinonMock } from 'sinon';
|
||||
|
||||
import ChooseAccountState from 'app/services/authFlow/ChooseAccountState';
|
||||
import CompleteState from 'app/services/authFlow/CompleteState';
|
||||
import LoginState from 'app/services/authFlow/LoginState';
|
||||
import { SinonMock } from 'sinon';
|
||||
|
||||
import { bootstrap, expectState, expectNavigate, expectRun, MockedAuthContext } from './helpers';
|
||||
|
||||
@ -49,10 +51,18 @@ describe('ChooseAccountState', () => {
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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', () => {
|
||||
@ -60,7 +70,8 @@ describe('ChooseAccountState', () => {
|
||||
expectRun(mock, 'setLogin', null);
|
||||
expectState(mock, LoginState);
|
||||
|
||||
state.resolve(context, {});
|
||||
// Assert nothing returned
|
||||
return expect(state.resolve(context, {}), 'to be undefined');
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -1,3 +1,5 @@
|
||||
import type { Account } from 'app/components/accounts/reducer';
|
||||
|
||||
import AbstractState from './AbstractState';
|
||||
import { AuthContext } from './AuthFlow';
|
||||
import LoginState from './LoginState';
|
||||
@ -14,14 +16,19 @@ export default class ChooseAccountState extends AbstractState {
|
||||
}
|
||||
}
|
||||
|
||||
resolve(context: AuthContext, payload: Record<string, any>): Promise<void> | void {
|
||||
resolve(context: AuthContext, payload: Account | Record<string, any>): Promise<void> | void {
|
||||
if (payload.id) {
|
||||
context.setState(new CompleteState());
|
||||
} else {
|
||||
context.navigate('/login');
|
||||
context.run('setLogin', null);
|
||||
context.setState(new LoginState());
|
||||
// payload is Account
|
||||
return context
|
||||
.run('authenticate', payload)
|
||||
.then(() => context.run('setAccountSwitcher', false))
|
||||
.then(() => context.setState(new CompleteState()));
|
||||
}
|
||||
|
||||
// log in to another account
|
||||
context.navigate('/login');
|
||||
context.run('setLogin', null);
|
||||
context.setState(new LoginState());
|
||||
}
|
||||
|
||||
reject(context: AuthContext): void {
|
||||
|
@ -22,8 +22,7 @@ describe('CompleteState', () => {
|
||||
state = new CompleteState();
|
||||
|
||||
const data = bootstrap();
|
||||
context = data.context;
|
||||
mock = data.mock;
|
||||
({ context, mock } = data);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -71,6 +70,22 @@ describe('CompleteState', () => {
|
||||
state.enter(context);
|
||||
});
|
||||
|
||||
it('should navigate to the / if account is deleted', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
isGuest: false,
|
||||
isActive: true,
|
||||
shouldAcceptRules: true,
|
||||
isDeleted: true,
|
||||
},
|
||||
auth: {},
|
||||
});
|
||||
|
||||
expectNavigate(mock, '/');
|
||||
|
||||
state.enter(context);
|
||||
});
|
||||
|
||||
it('should transition to accept-rules if shouldAcceptRules', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
@ -100,157 +115,188 @@ describe('CompleteState', () => {
|
||||
state.enter(context);
|
||||
});
|
||||
|
||||
it('should transition to finish state if code is present', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
isActive: true,
|
||||
isGuest: false,
|
||||
},
|
||||
auth: {
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
code: 'XXX',
|
||||
describe('oauth', () => {
|
||||
it('should transition to finish state if code is present', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
isActive: true,
|
||||
isGuest: false,
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
code: 'XXX',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectState(mock, FinishState);
|
||||
|
||||
state.enter(context);
|
||||
});
|
||||
|
||||
expectState(mock, FinishState);
|
||||
describe('permissions', () => {
|
||||
it('should transition to permissions state if acceptRequired', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
isActive: true,
|
||||
isGuest: false,
|
||||
},
|
||||
auth: {
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
acceptRequired: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
state.enter(context);
|
||||
});
|
||||
expectState(mock, PermissionsState);
|
||||
|
||||
it('should transition to permissions state if acceptRequired', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
isActive: true,
|
||||
isGuest: false,
|
||||
},
|
||||
auth: {
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
acceptRequired: true,
|
||||
},
|
||||
},
|
||||
state.enter(context);
|
||||
});
|
||||
|
||||
it('should transition to permissions state if prompt=consent', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
isActive: true,
|
||||
isGuest: false,
|
||||
},
|
||||
auth: {
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
prompt: ['consent'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectState(mock, PermissionsState);
|
||||
|
||||
state.enter(context);
|
||||
});
|
||||
});
|
||||
|
||||
expectState(mock, PermissionsState);
|
||||
describe('account switcher', () => {
|
||||
it('should transition to ChooseAccountState if user has multiple accs and switcher enabled', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
isActive: true,
|
||||
isGuest: false,
|
||||
},
|
||||
accounts: {
|
||||
available: [{ id: 1 }, { id: 2 }],
|
||||
active: 1,
|
||||
},
|
||||
auth: {
|
||||
isSwitcherEnabled: true,
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
prompt: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
state.enter(context);
|
||||
});
|
||||
expectState(mock, ChooseAccountState);
|
||||
|
||||
it('should transition to permissions state if prompt=consent', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
isActive: true,
|
||||
isGuest: false,
|
||||
},
|
||||
auth: {
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
prompt: ['consent'],
|
||||
},
|
||||
},
|
||||
state.enter(context);
|
||||
});
|
||||
|
||||
it('should transition to ChooseAccountState if user isDeleted', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
isActive: true,
|
||||
isDeleted: true,
|
||||
isGuest: false,
|
||||
},
|
||||
accounts: {
|
||||
available: [{ id: 1 }],
|
||||
active: 1,
|
||||
},
|
||||
auth: {
|
||||
isSwitcherEnabled: true,
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
prompt: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectState(mock, ChooseAccountState);
|
||||
|
||||
state.enter(context);
|
||||
});
|
||||
|
||||
it('should NOT transition to ChooseAccountState if user has multiple accs and switcher disabled', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
isActive: true,
|
||||
isGuest: false,
|
||||
},
|
||||
accounts: {
|
||||
available: [{ id: 1 }, { id: 2 }],
|
||||
active: 1,
|
||||
},
|
||||
auth: {
|
||||
isSwitcherEnabled: false,
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
prompt: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectRun(mock, 'oAuthComplete', {}).returns({ then() {} });
|
||||
|
||||
state.enter(context);
|
||||
});
|
||||
|
||||
it('should transition to ChooseAccountState if prompt=select_account and switcher enabled', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
isActive: true,
|
||||
isGuest: false,
|
||||
},
|
||||
accounts: {
|
||||
available: [{ id: 1 }],
|
||||
active: 1,
|
||||
},
|
||||
auth: {
|
||||
isSwitcherEnabled: true,
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
prompt: ['select_account'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectState(mock, ChooseAccountState);
|
||||
|
||||
state.enter(context);
|
||||
});
|
||||
|
||||
it('should NOT transition to ChooseAccountState if prompt=select_account and switcher disabled', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
isActive: true,
|
||||
isGuest: false,
|
||||
},
|
||||
accounts: {
|
||||
available: [{ id: 1 }],
|
||||
active: 1,
|
||||
},
|
||||
auth: {
|
||||
isSwitcherEnabled: false,
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
prompt: ['select_account'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectRun(mock, 'oAuthComplete', {}).returns({ then() {} });
|
||||
|
||||
state.enter(context);
|
||||
});
|
||||
});
|
||||
|
||||
expectState(mock, PermissionsState);
|
||||
|
||||
state.enter(context);
|
||||
});
|
||||
|
||||
it('should transition to ChooseAccountState if user has multiple accs and switcher enabled', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
isActive: true,
|
||||
isGuest: false,
|
||||
},
|
||||
accounts: {
|
||||
available: [{ id: 1 }, { id: 2 }],
|
||||
active: 1,
|
||||
},
|
||||
auth: {
|
||||
isSwitcherEnabled: true,
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
prompt: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectState(mock, ChooseAccountState);
|
||||
|
||||
state.enter(context);
|
||||
});
|
||||
|
||||
it('should NOT transition to ChooseAccountState if user has multiple accs and switcher disabled', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
isActive: true,
|
||||
isGuest: false,
|
||||
},
|
||||
accounts: {
|
||||
available: [{ id: 1 }, { id: 2 }],
|
||||
active: 1,
|
||||
},
|
||||
auth: {
|
||||
isSwitcherEnabled: false,
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
prompt: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectRun(mock, 'oAuthComplete', {}).returns({ then() {} });
|
||||
|
||||
state.enter(context);
|
||||
});
|
||||
|
||||
it('should transition to ChooseAccountState if prompt=select_account and switcher enabled', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
isActive: true,
|
||||
isGuest: false,
|
||||
},
|
||||
accounts: {
|
||||
available: [{ id: 1 }],
|
||||
active: 1,
|
||||
},
|
||||
auth: {
|
||||
isSwitcherEnabled: true,
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
prompt: ['select_account'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectState(mock, ChooseAccountState);
|
||||
|
||||
state.enter(context);
|
||||
});
|
||||
|
||||
it('should NOT transition to ChooseAccountState if prompt=select_account and switcher disabled', () => {
|
||||
context.getState.returns({
|
||||
user: {
|
||||
isActive: true,
|
||||
isGuest: false,
|
||||
},
|
||||
accounts: {
|
||||
available: [{ id: 1 }],
|
||||
active: 1,
|
||||
},
|
||||
auth: {
|
||||
isSwitcherEnabled: false,
|
||||
oauth: {
|
||||
clientId: 'ely.by',
|
||||
prompt: ['select_account'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expectRun(mock, 'oAuthComplete', {}).returns({ then() {} });
|
||||
|
||||
state.enter(context);
|
||||
});
|
||||
});
|
||||
|
||||
@ -374,6 +420,7 @@ describe('CompleteState', () => {
|
||||
username: 'thatUsername',
|
||||
token: '',
|
||||
refreshToken: '',
|
||||
isDeleted: false,
|
||||
};
|
||||
|
||||
context.getState.returns({
|
||||
|
@ -34,7 +34,7 @@ export default class CompleteState extends AbstractState {
|
||||
context.setState(new LoginState());
|
||||
} else if (!user.isActive) {
|
||||
context.setState(new ActivationState());
|
||||
} else if (user.shouldAcceptRules) {
|
||||
} else if (user.shouldAcceptRules && !user.isDeleted) {
|
||||
context.setState(new AcceptRulesState());
|
||||
} else if (oauth && oauth.clientId) {
|
||||
return this.processOAuth(context);
|
||||
@ -44,7 +44,7 @@ export default class CompleteState extends AbstractState {
|
||||
}
|
||||
|
||||
processOAuth(context: AuthContext): Promise<void> | void {
|
||||
const { auth, accounts } = context.getState();
|
||||
const { auth, accounts, user } = context.getState();
|
||||
|
||||
let { isSwitcherEnabled } = auth;
|
||||
const { oauth } = auth;
|
||||
@ -73,8 +73,22 @@ export default class CompleteState extends AbstractState {
|
||||
}
|
||||
}
|
||||
|
||||
if (isSwitcherEnabled && (accounts.available.length > 1 || oauth.prompt.includes(PROMPT_ACCOUNT_CHOOSE))) {
|
||||
if (
|
||||
isSwitcherEnabled &&
|
||||
(accounts.available.length > 1 ||
|
||||
// we are always showing account switcher for deleted users
|
||||
// so that they can see, that their account was deleted
|
||||
// (this info is displayed on switcher)
|
||||
user.isDeleted ||
|
||||
oauth.prompt.includes(PROMPT_ACCOUNT_CHOOSE))
|
||||
) {
|
||||
context.setState(new ChooseAccountState());
|
||||
} else if (user.isDeleted) {
|
||||
// you shall not pass
|
||||
// if we are here, this means that user have already seen account
|
||||
// switcher and now we should redirect him to his profile,
|
||||
// because oauth is not available for deleted accounts
|
||||
context.navigate('/');
|
||||
} else if (oauth.code) {
|
||||
context.setState(new FinishState());
|
||||
} else {
|
||||
|
@ -74,6 +74,7 @@ describe('MfaState', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expectRun(mock, 'setAccountSwitcher', false);
|
||||
expectRun(
|
||||
mock,
|
||||
'login',
|
||||
|
@ -19,15 +19,16 @@ export default class MfaState extends AbstractState {
|
||||
}
|
||||
|
||||
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
|
||||
.run('login', {
|
||||
totp,
|
||||
password,
|
||||
rememberMe,
|
||||
login,
|
||||
password,
|
||||
totp,
|
||||
rememberMe,
|
||||
})
|
||||
.then(() => !isRelogin && context.run('setAccountSwitcher', false))
|
||||
.then(() => context.setState(new CompleteState()))
|
||||
.catch((err = {}) => err.errors || logger.warn('Error logging in', err));
|
||||
}
|
||||
|
@ -69,6 +69,7 @@ describe('PasswordState', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expectRun(mock, 'setAccountSwitcher', false);
|
||||
expectRun(
|
||||
mock,
|
||||
'login',
|
||||
@ -102,6 +103,8 @@ describe('PasswordState', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Should not run "setAccountSwitcher"
|
||||
|
||||
expectRun(
|
||||
mock,
|
||||
'login',
|
||||
@ -136,6 +139,7 @@ describe('PasswordState', () => {
|
||||
},
|
||||
});
|
||||
|
||||
expectRun(mock, 'setAccountSwitcher', false);
|
||||
expectRun(
|
||||
mock,
|
||||
'login',
|
||||
@ -194,6 +198,7 @@ describe('PasswordState', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Should not run "setAccountSwitcher"
|
||||
expectRun(mock, 'activateAccount', { id: 2 });
|
||||
expectRun(mock, 'removeAccount', { id: 1 });
|
||||
expectState(mock, ChooseAccountState);
|
||||
|
@ -33,7 +33,7 @@ export default class PasswordState extends AbstractState {
|
||||
rememberMe: boolean;
|
||||
},
|
||||
): Promise<void> | void {
|
||||
const { login, returnUrl } = getCredentials(context.getState());
|
||||
const { login, returnUrl, isRelogin } = getCredentials(context.getState());
|
||||
|
||||
return context
|
||||
.run('login', {
|
||||
@ -48,6 +48,10 @@ export default class PasswordState extends AbstractState {
|
||||
return context.setState(new MfaState());
|
||||
}
|
||||
|
||||
if (!isRelogin) {
|
||||
context.run('setAccountSwitcher', false);
|
||||
}
|
||||
|
||||
if (returnUrl) {
|
||||
context.navigate(returnUrl);
|
||||
|
||||
|
@ -11,8 +11,8 @@ import ContextProvider from './ContextProvider';
|
||||
|
||||
import type { History } from 'history';
|
||||
|
||||
const SuccessOauthPage = React.lazy(() =>
|
||||
import(/* webpackChunkName: "page-oauth-success" */ 'app/pages/auth/SuccessOauthPage'),
|
||||
const SuccessOauthPage = React.lazy(
|
||||
() => import(/* webpackChunkName: "page-oauth-success" */ 'app/pages/auth/SuccessOauthPage'),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
|
@ -28,7 +28,12 @@ export default function storeFactory(preloadedState = {}): Store {
|
||||
|
||||
// Hot reload reducers
|
||||
if (module.hot && typeof module.hot.accept === 'function') {
|
||||
module.hot.accept('app/reducers', () => store.replaceReducer(require('app/reducers').default));
|
||||
module.hot.accept('app/reducers', () =>
|
||||
store.replaceReducer(
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
require('app/reducers').default,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return store;
|
||||
|
@ -2,7 +2,8 @@
|
||||
/* eslint-env node */
|
||||
/* eslint-disable no-console */
|
||||
|
||||
import fs, { Stats } from 'fs';
|
||||
import type { Stats } from 'fs';
|
||||
import fs from 'fs';
|
||||
import webpack, { MultiCompiler } from 'webpack';
|
||||
import chalk from 'chalk';
|
||||
|
||||
@ -37,7 +38,11 @@ Promise.all([stat(`${__dirname}/../../yarn.lock`), stat(`${__dirname}/../../dll/
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
logResult(chalk.green('Dll was successfully build in %s ms'), stats.endTime! - stats.startTime!);
|
||||
logResult(
|
||||
chalk.green('Dll was successfully build in %s ms'),
|
||||
// @ts-expect-error - something wrong with webpack types
|
||||
stats.endTime - stats.startTime,
|
||||
);
|
||||
|
||||
resolve();
|
||||
});
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { account1 } from '../../fixtures/accounts.json';
|
||||
import { UserResponse } from 'app/services/api/accounts';
|
||||
|
||||
const defaults = {
|
||||
client_id: 'ely',
|
||||
@ -39,108 +40,186 @@ describe('OAuth', () => {
|
||||
cy.url().should('equal', 'https://dev.ely.by/');
|
||||
});
|
||||
|
||||
it('should ask to choose an account if user has multiple', () => {
|
||||
cy.login({ accounts: ['default', 'default2'] }).then(({ accounts: [account] }) => {
|
||||
describe('AccountSwitcher', () => {
|
||||
it('should ask to choose an account if user has multiple', () => {
|
||||
cy.login({ accounts: ['default', 'default2'] }).then(({ accounts: [account] }) => {
|
||||
cy.visit(`/oauth2/v1/ely?${new URLSearchParams(defaults)}`);
|
||||
|
||||
cy.url().should('include', '/oauth/choose-account');
|
||||
|
||||
cy.findByTestId('auth-header').should('contain', 'Choose an account');
|
||||
|
||||
cy.findByTestId('auth-body').contains(account.email).click();
|
||||
|
||||
cy.url().should('equal', 'https://dev.ely.by/');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Permissions prompt', () => {
|
||||
// TODO: remove api mocks, when we will be able to revoke permissions
|
||||
it('should prompt for permissions', () => {
|
||||
cy.server();
|
||||
|
||||
cy.route({
|
||||
method: 'POST',
|
||||
// NOTE: can not use cypress glob syntax, because it will break due to
|
||||
// '%2F%2F' (//) in redirect_uri
|
||||
// url: '/api/oauth2/v1/complete/*',
|
||||
url: new RegExp('/api/oauth2/v1/complete'),
|
||||
response: {
|
||||
statusCode: 401,
|
||||
error: 'accept_required',
|
||||
},
|
||||
status: 401,
|
||||
}).as('complete');
|
||||
|
||||
cy.login({ accounts: ['default'] });
|
||||
|
||||
cy.visit(
|
||||
`/oauth2/v1/ely?${new URLSearchParams({
|
||||
...defaults,
|
||||
client_id: 'tlauncher',
|
||||
redirect_uri: 'http://localhost:8080',
|
||||
})}`,
|
||||
);
|
||||
|
||||
cy.wait('@complete');
|
||||
|
||||
assertPermissions();
|
||||
|
||||
cy.server({ enable: false });
|
||||
|
||||
cy.findByTestId('auth-controls').contains('Approve').click();
|
||||
|
||||
cy.url().should('match', /^http:\/\/localhost:8080\/?\?code=[^&]+&state=$/);
|
||||
});
|
||||
|
||||
// TODO: enable, when backend api will return correct response on auth decline
|
||||
xit('should redirect to error page, when permission request declined', () => {
|
||||
cy.server();
|
||||
|
||||
cy.route({
|
||||
method: 'POST',
|
||||
// NOTE: can not use cypress glob syntax, because it will break due to
|
||||
// '%2F%2F' (//) in redirect_uri
|
||||
// url: '/api/oauth2/v1/complete/*',
|
||||
url: new RegExp('/api/oauth2/v1/complete'),
|
||||
response: {
|
||||
statusCode: 401,
|
||||
error: 'accept_required',
|
||||
},
|
||||
status: 401,
|
||||
}).as('complete');
|
||||
|
||||
cy.login({ accounts: ['default'] });
|
||||
|
||||
cy.visit(
|
||||
`/oauth2/v1/ely?${new URLSearchParams({
|
||||
...defaults,
|
||||
client_id: 'tlauncher',
|
||||
redirect_uri: 'http://localhost:8080',
|
||||
})}`,
|
||||
);
|
||||
|
||||
cy.wait('@complete');
|
||||
|
||||
assertPermissions();
|
||||
|
||||
cy.server({ enable: false });
|
||||
|
||||
cy.findByTestId('auth-controls-secondary').contains('Decline').click();
|
||||
|
||||
cy.url().should('include', 'error=access_denied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sign-in during oauth', () => {
|
||||
it('should allow sign in during oauth (guest oauth)', () => {
|
||||
cy.visit(`/oauth2/v1/ely?${new URLSearchParams(defaults)}`);
|
||||
|
||||
cy.url().should('include', '/oauth/choose-account');
|
||||
cy.location('pathname').should('eq', '/login');
|
||||
|
||||
cy.findByTestId('auth-header').should('contain', 'Choose an account');
|
||||
cy.get('[name=login]').type(`${account1.login}{enter}`);
|
||||
|
||||
cy.findByTestId('auth-body').contains(account.email).click();
|
||||
cy.url().should('include', '/password');
|
||||
|
||||
cy.get('[name=password]').type(`${account1.password}{enter}`);
|
||||
|
||||
cy.url().should('equal', 'https://dev.ely.by/');
|
||||
});
|
||||
});
|
||||
|
||||
// TODO: remove api mocks, when we will be able to revoke permissions
|
||||
it('should prompt for permissions', () => {
|
||||
cy.server();
|
||||
describe('Deleted account', () => {
|
||||
it('should show account switcher and then abort oauth and redirect to profile', () => {
|
||||
cy.login({ accounts: ['default'] }).then(({ accounts: [account] }) => {
|
||||
cy.server();
|
||||
cy.route({
|
||||
method: 'GET',
|
||||
url: `/api/v1/accounts/${account1.id}`,
|
||||
response: {
|
||||
id: account.id,
|
||||
uuid: '522e8c19-89d8-4a6d-a2ec-72ebb58c2dbe',
|
||||
username: account.username,
|
||||
isOtpEnabled: false,
|
||||
registeredAt: 1475568334,
|
||||
lang: 'en',
|
||||
elyProfileLink: 'http://ely.by/u7',
|
||||
email: account.email,
|
||||
isActive: true,
|
||||
isDeleted: true, // force user into the deleted state
|
||||
passwordChangedAt: 1476075696,
|
||||
hasMojangUsernameCollision: true,
|
||||
shouldAcceptRules: false,
|
||||
} as UserResponse,
|
||||
});
|
||||
|
||||
cy.route({
|
||||
method: 'POST',
|
||||
// NOTE: can not use cypress glob syntax, because it will break due to
|
||||
// '%2F%2F' (//) in redirect_uri
|
||||
// url: '/api/oauth2/v1/complete/*',
|
||||
url: new RegExp('/api/oauth2/v1/complete'),
|
||||
response: {
|
||||
statusCode: 401,
|
||||
error: 'accept_required',
|
||||
},
|
||||
status: 401,
|
||||
}).as('complete');
|
||||
cy.visit(`/oauth2/v1/ely?${new URLSearchParams(defaults)}`);
|
||||
|
||||
cy.login({ accounts: ['default'] });
|
||||
cy.findByTestId('auth-header').should('contain', 'Choose an account');
|
||||
|
||||
cy.visit(
|
||||
`/oauth2/v1/ely?${new URLSearchParams({
|
||||
...defaults,
|
||||
client_id: 'tlauncher',
|
||||
redirect_uri: 'http://localhost:8080',
|
||||
})}`,
|
||||
);
|
||||
cy.findByTestId('auth-body').contains(account.email).click();
|
||||
|
||||
cy.wait('@complete');
|
||||
cy.location('pathname').should('eq', '/');
|
||||
cy.findByTestId('deletedAccount').should('contain', 'Account is deleted');
|
||||
});
|
||||
});
|
||||
|
||||
assertPermissions();
|
||||
it('should allow sign and then abort oauth and redirect to profile', () => {
|
||||
cy.visit(`/oauth2/v1/ely?${new URLSearchParams(defaults)}`);
|
||||
|
||||
cy.server({ enable: false });
|
||||
cy.location('pathname').should('eq', '/login');
|
||||
|
||||
cy.findByTestId('auth-controls').contains('Approve').click();
|
||||
cy.get('[name=login]').type(`${account1.login}{enter}`);
|
||||
|
||||
cy.url().should('match', /^http:\/\/localhost:8080\/?\?code=[^&]+&state=$/);
|
||||
});
|
||||
cy.url().should('include', '/password');
|
||||
|
||||
it('should allow sign in during oauth (guest oauth)', () => {
|
||||
cy.visit(`/oauth2/v1/ely?${new URLSearchParams(defaults)}`);
|
||||
cy.server();
|
||||
cy.route({
|
||||
method: 'GET',
|
||||
url: `/api/v1/accounts/${account1.id}`,
|
||||
response: {
|
||||
id: 7,
|
||||
uuid: '522e8c19-89d8-4a6d-a2ec-72ebb58c2dbe',
|
||||
username: 'SleepWalker',
|
||||
isOtpEnabled: false,
|
||||
registeredAt: 1475568334,
|
||||
lang: 'en',
|
||||
elyProfileLink: 'http://ely.by/u7',
|
||||
email: 'danilenkos@auroraglobal.com',
|
||||
isActive: true,
|
||||
isDeleted: true, // force user into the deleted state
|
||||
passwordChangedAt: 1476075696,
|
||||
hasMojangUsernameCollision: true,
|
||||
shouldAcceptRules: false,
|
||||
} as UserResponse,
|
||||
});
|
||||
|
||||
cy.url().should('include', '/login');
|
||||
cy.get('[name=password]').type(`${account1.password}{enter}`);
|
||||
|
||||
cy.get('[name=login]').type(`${account1.login}{enter}`);
|
||||
|
||||
cy.url().should('include', '/password');
|
||||
|
||||
cy.get('[name=password]').type(`${account1.password}{enter}`);
|
||||
|
||||
cy.url().should('equal', 'https://dev.ely.by/');
|
||||
});
|
||||
|
||||
// TODO: enable, when backend api will return correct response on auth decline
|
||||
xit('should redirect to error page, when permission request declined', () => {
|
||||
cy.server();
|
||||
|
||||
cy.route({
|
||||
method: 'POST',
|
||||
// NOTE: can not use cypress glob syntax, because it will break due to
|
||||
// '%2F%2F' (//) in redirect_uri
|
||||
// url: '/api/oauth2/v1/complete/*',
|
||||
url: new RegExp('/api/oauth2/v1/complete'),
|
||||
response: {
|
||||
statusCode: 401,
|
||||
error: 'accept_required',
|
||||
},
|
||||
status: 401,
|
||||
}).as('complete');
|
||||
|
||||
cy.login({ accounts: ['default'] });
|
||||
|
||||
cy.visit(
|
||||
`/oauth2/v1/ely?${new URLSearchParams({
|
||||
...defaults,
|
||||
client_id: 'tlauncher',
|
||||
redirect_uri: 'http://localhost:8080',
|
||||
})}`,
|
||||
);
|
||||
|
||||
cy.wait('@complete');
|
||||
|
||||
assertPermissions();
|
||||
|
||||
cy.server({ enable: false });
|
||||
|
||||
cy.findByTestId('auth-controls-secondary').contains('Decline').click();
|
||||
|
||||
cy.url().should('include', 'error=access_denied');
|
||||
cy.location('pathname').should('eq', '/');
|
||||
cy.findByTestId('deletedAccount').should('contain', 'Account is deleted');
|
||||
});
|
||||
});
|
||||
|
||||
describe('login_hint', () => {
|
||||
@ -229,7 +308,7 @@ describe('OAuth', () => {
|
||||
|
||||
cy.findByTestId('auth-controls').contains('another account').click();
|
||||
|
||||
cy.url().should('include', '/login');
|
||||
cy.location('pathname').should('eq', '/login');
|
||||
|
||||
cy.get('[name=login]').type(`${account1.login}{enter}`);
|
||||
|
||||
@ -314,7 +393,7 @@ describe('OAuth', () => {
|
||||
})}`,
|
||||
);
|
||||
|
||||
cy.url().should('include', '/login');
|
||||
cy.location('pathname').should('eq', '/login');
|
||||
|
||||
cy.get('[name=login]').type(`${account1.login}{enter}`);
|
||||
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { account1, account2 } from '../../fixtures/accounts.json';
|
||||
import { UserResponse } from 'app/services/api/accounts';
|
||||
import { confirmWithPassword } from '../profile/utils';
|
||||
|
||||
describe('Sign in / Log out', () => {
|
||||
it('should sign in', () => {
|
||||
@ -131,6 +133,189 @@ describe('Sign in / Log out', () => {
|
||||
cy.findByTestId('toolbar').should('contain', 'Join');
|
||||
});
|
||||
|
||||
it("should prompt for user agreement when the project's rules are changed", () => {
|
||||
cy.visit('/');
|
||||
|
||||
cy.get('[name=login]').type(`${account1.login}{enter}`);
|
||||
|
||||
cy.url().should('include', '/password');
|
||||
|
||||
cy.get('[name=password]').type(account1.password);
|
||||
cy.get('[name=rememberMe]').should('be.checked');
|
||||
|
||||
cy.server();
|
||||
cy.route({
|
||||
method: 'POST',
|
||||
url: `/api/v1/accounts/${account1.id}/rules`,
|
||||
}).as('rulesAgreement');
|
||||
cy.route({
|
||||
method: 'GET',
|
||||
url: `/api/v1/accounts/${account1.id}`,
|
||||
response: {
|
||||
id: 7,
|
||||
uuid: '522e8c19-89d8-4a6d-a2ec-72ebb58c2dbe',
|
||||
username: 'SleepWalker',
|
||||
isOtpEnabled: false,
|
||||
registeredAt: 1475568334,
|
||||
lang: 'en',
|
||||
elyProfileLink: 'http://ely.by/u7',
|
||||
email: 'danilenkos@auroraglobal.com',
|
||||
isActive: true,
|
||||
isDeleted: false,
|
||||
passwordChangedAt: 1476075696,
|
||||
hasMojangUsernameCollision: true,
|
||||
shouldAcceptRules: true, // force user to accept updated user agreement
|
||||
} as UserResponse,
|
||||
});
|
||||
|
||||
cy.get('[type=submit]').click();
|
||||
|
||||
cy.location('pathname').should('eq', '/accept-rules');
|
||||
|
||||
cy.get('[type=submit]').last().click(); // add .last() to match the new state during its transition
|
||||
cy.wait('@rulesAgreement').its('requestBody').should('be.empty');
|
||||
|
||||
cy.location('pathname').should('eq', '/');
|
||||
cy.findByTestId('profile-index').should('contain', account1.username);
|
||||
});
|
||||
|
||||
it('should allow logout from the user agreement prompt', () => {
|
||||
cy.visit('/');
|
||||
|
||||
cy.get('[name=login]').type(`${account1.login}{enter}`);
|
||||
|
||||
cy.url().should('include', '/password');
|
||||
|
||||
cy.get('[name=password]').type(account1.password);
|
||||
cy.get('[name=rememberMe]').should('be.checked');
|
||||
|
||||
cy.server();
|
||||
cy.route({
|
||||
method: 'GET',
|
||||
url: `/api/v1/accounts/${account1.id}`,
|
||||
response: {
|
||||
id: 7,
|
||||
uuid: '522e8c19-89d8-4a6d-a2ec-72ebb58c2dbe',
|
||||
username: 'SleepWalker',
|
||||
isOtpEnabled: false,
|
||||
registeredAt: 1475568334,
|
||||
lang: 'en',
|
||||
elyProfileLink: 'http://ely.by/u7',
|
||||
email: 'danilenkos@auroraglobal.com',
|
||||
isActive: true,
|
||||
isDeleted: false,
|
||||
passwordChangedAt: 1476075696,
|
||||
hasMojangUsernameCollision: true,
|
||||
shouldAcceptRules: true, // force user to accept updated user agreement
|
||||
} as UserResponse,
|
||||
});
|
||||
|
||||
cy.get('[type=submit]').click();
|
||||
|
||||
cy.location('pathname').should('eq', '/accept-rules');
|
||||
|
||||
cy.findByText('Decline and logout').click();
|
||||
|
||||
cy.location('pathname').should('eq', '/login');
|
||||
cy.findByTestId('toolbar').should('contain', 'Join');
|
||||
});
|
||||
|
||||
it('should allow user to delete its own account from the user agreement prompt', () => {
|
||||
cy.visit('/');
|
||||
|
||||
cy.get('[name=login]').type(`${account1.login}{enter}`);
|
||||
|
||||
cy.url().should('include', '/password');
|
||||
|
||||
cy.get('[name=password]').type(account1.password);
|
||||
cy.get('[name=rememberMe]').should('be.checked');
|
||||
|
||||
cy.server();
|
||||
cy.route({
|
||||
method: 'GET',
|
||||
url: `/api/v1/accounts/${account1.id}`,
|
||||
response: {
|
||||
id: 7,
|
||||
uuid: '522e8c19-89d8-4a6d-a2ec-72ebb58c2dbe',
|
||||
username: 'SleepWalker',
|
||||
isOtpEnabled: false,
|
||||
registeredAt: 1475568334,
|
||||
lang: 'en',
|
||||
elyProfileLink: 'http://ely.by/u7',
|
||||
email: 'danilenkos@auroraglobal.com',
|
||||
isActive: true,
|
||||
isDeleted: false,
|
||||
passwordChangedAt: 1476075696,
|
||||
hasMojangUsernameCollision: true,
|
||||
shouldAcceptRules: true, // force user to accept updated user agreement
|
||||
} as UserResponse,
|
||||
});
|
||||
|
||||
cy.get('[type=submit]').click();
|
||||
|
||||
cy.location('pathname').should('eq', '/accept-rules');
|
||||
|
||||
cy.findByText('Delete account').click();
|
||||
|
||||
cy.location('pathname').should('eq', '/profile/delete');
|
||||
|
||||
cy.route({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/accounts/${account1.id}`,
|
||||
}).as('deleteAccount');
|
||||
cy.route({
|
||||
method: 'GET',
|
||||
url: `/api/v1/accounts/${account1.id}`,
|
||||
response: {
|
||||
id: 7,
|
||||
uuid: '522e8c19-89d8-4a6d-a2ec-72ebb58c2dbe',
|
||||
username: 'SleepWalker',
|
||||
isOtpEnabled: false,
|
||||
registeredAt: 1475568334,
|
||||
lang: 'en',
|
||||
elyProfileLink: 'http://ely.by/u7',
|
||||
email: 'danilenkos@auroraglobal.com',
|
||||
isActive: true,
|
||||
isDeleted: true, // mock deleted state since the delete will not perform the real request
|
||||
passwordChangedAt: 1476075696,
|
||||
hasMojangUsernameCollision: true,
|
||||
shouldAcceptRules: true, // rules still aren't accepted
|
||||
} as UserResponse,
|
||||
});
|
||||
|
||||
cy.get('[type=submit]').click();
|
||||
|
||||
cy.wait('@deleteAccount')
|
||||
.its('requestBody')
|
||||
.should(
|
||||
'eq',
|
||||
new URLSearchParams({
|
||||
password: '',
|
||||
}).toString(),
|
||||
);
|
||||
|
||||
cy.route({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/accounts/${account1.id}`,
|
||||
response: { success: true },
|
||||
}).as('deleteAccount');
|
||||
|
||||
confirmWithPassword(account1.password);
|
||||
|
||||
cy.wait('@deleteAccount')
|
||||
.its('requestBody')
|
||||
.should(
|
||||
'eq',
|
||||
new URLSearchParams({
|
||||
password: account1.password,
|
||||
}).toString(),
|
||||
);
|
||||
|
||||
cy.location('pathname').should('eq', '/');
|
||||
|
||||
cy.findByTestId('deletedAccount').should('contain', 'Account is deleted');
|
||||
});
|
||||
|
||||
describe('multi account', () => {
|
||||
it('should allow sign in with another account', () => {
|
||||
cy.login({ accounts: ['default2'] });
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { UserResponse } from 'app/services/api/accounts';
|
||||
|
||||
import { openSectionByName, confirmWithPassword } from './utils';
|
||||
|
||||
describe('Profile — Change Username', () => {
|
||||
@ -18,10 +20,11 @@ describe('Profile — Change Username', () => {
|
||||
elyProfileLink: 'http://ely.by/u7',
|
||||
email: 'danilenkos@auroraglobal.com',
|
||||
isActive: true,
|
||||
isDeleted: false,
|
||||
passwordChangedAt: 1476075696,
|
||||
hasMojangUsernameCollision: true,
|
||||
shouldAcceptRules: false,
|
||||
},
|
||||
} as UserResponse,
|
||||
});
|
||||
|
||||
cy.route({
|
||||
|
75
tests-e2e/cypress/integration/profile/delete-account.test.ts
Normal file
75
tests-e2e/cypress/integration/profile/delete-account.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { openSectionByName, confirmWithPassword } from './utils';
|
||||
import { UserResponse } from 'app/services/api/accounts';
|
||||
|
||||
describe('Profile — Delete account', () => {
|
||||
it('should delete account', () => {
|
||||
cy.login({ accounts: ['default'] }).then(({ accounts: [account] }) => {
|
||||
cy.server();
|
||||
cy.route({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/accounts/${account.id}`,
|
||||
}).as('deleteAccount');
|
||||
cy.route({
|
||||
method: 'GET',
|
||||
url: `/api/v1/accounts/${account.id}`,
|
||||
});
|
||||
|
||||
cy.visit('/');
|
||||
|
||||
openSectionByName('Account deletion');
|
||||
|
||||
cy.location('pathname').should('eq', '/profile/delete');
|
||||
|
||||
cy.get('[type=submit]').click();
|
||||
|
||||
cy.wait('@deleteAccount')
|
||||
.its('requestBody')
|
||||
.should(
|
||||
'eq',
|
||||
new URLSearchParams({
|
||||
password: '',
|
||||
}).toString(),
|
||||
);
|
||||
|
||||
cy.route({
|
||||
method: 'DELETE',
|
||||
url: `/api/v1/accounts/${account.id}`,
|
||||
response: { success: true },
|
||||
}).as('deleteAccount');
|
||||
cy.route({
|
||||
method: 'GET',
|
||||
url: `/api/v1/accounts/${account.id}`,
|
||||
response: {
|
||||
id: 7,
|
||||
uuid: '522e8c19-89d8-4a6d-a2ec-72ebb58c2dbe',
|
||||
username: 'SleepWalker',
|
||||
isOtpEnabled: false,
|
||||
registeredAt: 1475568334,
|
||||
lang: 'en',
|
||||
elyProfileLink: 'http://ely.by/u7',
|
||||
email: 'danilenkos@auroraglobal.com',
|
||||
isActive: true,
|
||||
isDeleted: true, // mock deleted state since the delete will not perform the real request
|
||||
passwordChangedAt: 1476075696,
|
||||
hasMojangUsernameCollision: true,
|
||||
shouldAcceptRules: false,
|
||||
} as UserResponse,
|
||||
});
|
||||
|
||||
confirmWithPassword(account.password);
|
||||
|
||||
cy.wait('@deleteAccount')
|
||||
.its('requestBody')
|
||||
.should(
|
||||
'eq',
|
||||
new URLSearchParams({
|
||||
password: account.password,
|
||||
}).toString(),
|
||||
);
|
||||
|
||||
cy.location('pathname').should('eq', '/');
|
||||
|
||||
cy.findByTestId('deletedAccount').should('contain', 'Account is deleted');
|
||||
});
|
||||
});
|
||||
});
|
@ -1,3 +1,5 @@
|
||||
import { UserResponse } from 'app/services/api/accounts';
|
||||
|
||||
import { openSectionByName, getSectionByName, confirmWithPassword } from './utils';
|
||||
|
||||
describe('Profile — mfa', () => {
|
||||
@ -63,10 +65,11 @@ describe('Profile — mfa', () => {
|
||||
elyProfileLink: 'http://ely.by/u7',
|
||||
email: 'danilenkos@auroraglobal.com',
|
||||
isActive: true,
|
||||
isDeleted: false,
|
||||
passwordChangedAt: 1476075696,
|
||||
hasMojangUsernameCollision: true,
|
||||
shouldAcceptRules: false,
|
||||
},
|
||||
} as UserResponse,
|
||||
});
|
||||
|
||||
confirmWithPassword(account.password);
|
||||
@ -104,10 +107,11 @@ describe('Profile — mfa', () => {
|
||||
elyProfileLink: 'http://ely.by/u7',
|
||||
email: 'danilenkos@auroraglobal.com',
|
||||
isActive: true,
|
||||
isDeleted: false,
|
||||
passwordChangedAt: 1476075696,
|
||||
hasMojangUsernameCollision: true,
|
||||
shouldAcceptRules: false,
|
||||
},
|
||||
} as UserResponse,
|
||||
});
|
||||
cy.route({
|
||||
method: 'DELETE',
|
||||
|
@ -0,0 +1,63 @@
|
||||
import { UserResponse } from 'app/services/api/accounts';
|
||||
|
||||
describe('Profile — Restore account', () => {
|
||||
it('should restore account', () => {
|
||||
cy.login({ accounts: ['default'] }).then(({ accounts: [account] }) => {
|
||||
cy.server();
|
||||
cy.route({
|
||||
method: 'GET',
|
||||
url: `/api/v1/accounts/${account.id}`,
|
||||
response: {
|
||||
id: 7,
|
||||
uuid: '522e8c19-89d8-4a6d-a2ec-72ebb58c2dbe',
|
||||
username: 'FooBar',
|
||||
isOtpEnabled: false,
|
||||
registeredAt: 1475568334,
|
||||
lang: 'en',
|
||||
elyProfileLink: 'http://ely.by/u7',
|
||||
email: 'danilenkos@auroraglobal.com',
|
||||
isActive: true,
|
||||
isDeleted: true, // force deleted state
|
||||
passwordChangedAt: 1476075696,
|
||||
hasMojangUsernameCollision: true,
|
||||
shouldAcceptRules: false,
|
||||
} as UserResponse,
|
||||
});
|
||||
cy.route({
|
||||
method: 'POST',
|
||||
url: `/api/v1/accounts/${account.id}/restore`,
|
||||
response: { success: true },
|
||||
}).as('restoreAccount');
|
||||
|
||||
cy.visit('/');
|
||||
|
||||
cy.route({
|
||||
method: 'GET',
|
||||
url: `/api/v1/accounts/${account.id}`,
|
||||
response: {
|
||||
id: 7,
|
||||
uuid: '522e8c19-89d8-4a6d-a2ec-72ebb58c2dbe',
|
||||
username: 'SleepWalker',
|
||||
isOtpEnabled: false,
|
||||
registeredAt: 1475568334,
|
||||
lang: 'en',
|
||||
elyProfileLink: 'http://ely.by/u7',
|
||||
email: 'danilenkos@auroraglobal.com',
|
||||
isActive: true,
|
||||
isDeleted: false, // force deleted state
|
||||
passwordChangedAt: 1476075696,
|
||||
hasMojangUsernameCollision: true,
|
||||
shouldAcceptRules: false,
|
||||
} as UserResponse,
|
||||
});
|
||||
|
||||
cy.findByTestId('deletedAccount').contains('Restore account').click();
|
||||
|
||||
cy.wait('@restoreAccount');
|
||||
|
||||
cy.location('pathname').should('eq', '/');
|
||||
|
||||
cy.findByTestId('profile-index').should('contain', account.username);
|
||||
});
|
||||
});
|
||||
});
|
@ -21,6 +21,7 @@ const wp = require('@cypress/webpack-preprocessor');
|
||||
|
||||
// for some reason loader can not locate babel.config. So we load it manually
|
||||
const config = require('../../../babel.config');
|
||||
const babelEnvName = 'browser-development';
|
||||
|
||||
module.exports = (on) => {
|
||||
const options = {
|
||||
@ -39,14 +40,31 @@ module.exports = (on) => {
|
||||
{
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
envName: 'webpack',
|
||||
envName: babelEnvName,
|
||||
cacheDirectory: true,
|
||||
// We don't have the webpack's API object, so just provide necessary methods
|
||||
...config({
|
||||
env() {
|
||||
return 'development';
|
||||
env(value) {
|
||||
// see @babel/core/lib/config/helpers/config-api.js
|
||||
switch (typeof value) {
|
||||
case 'string':
|
||||
return value === babelEnvName;
|
||||
case 'function':
|
||||
return value(babelEnvName);
|
||||
|
||||
case 'undefined':
|
||||
return babelEnvName;
|
||||
default:
|
||||
if (Array.isArray(value)) {
|
||||
throw new Error('Unimplemented env() argument');
|
||||
}
|
||||
|
||||
throw new Error('Invalid env() argument');
|
||||
}
|
||||
},
|
||||
cache: {
|
||||
using() {},
|
||||
},
|
||||
cache() {},
|
||||
}),
|
||||
},
|
||||
},
|
||||
|
@ -30,13 +30,14 @@ const isCI = !!process.env.CI;
|
||||
const isSilent = isCI || process.argv.some((arg) => /quiet/.test(arg));
|
||||
const isCspEnabled = false;
|
||||
const enableDll = !isProduction && !isStorybook;
|
||||
const webpackEnv = isProduction ? 'production' : 'development';
|
||||
|
||||
process.env.NODE_ENV = isProduction ? 'production' : 'development';
|
||||
process.env.NODE_ENV = webpackEnv;
|
||||
|
||||
const smp = new SpeedMeasurePlugin();
|
||||
|
||||
const webpackConfig = {
|
||||
mode: isProduction ? 'production' : 'development',
|
||||
mode: webpackEnv,
|
||||
|
||||
cache: true,
|
||||
|
||||
@ -169,7 +170,7 @@ const webpackConfig = {
|
||||
{
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
envName: 'webpack',
|
||||
envName: `browser-${webpackEnv}`,
|
||||
cacheDirectory: true,
|
||||
},
|
||||
},
|
||||
|
Loading…
x
Reference in New Issue
Block a user