Merge branch 'account_deletion' into master

This commit is contained in:
ErickSkrauch
2020-11-19 15:40:08 +01:00
90 changed files with 3929 additions and 2769 deletions

View File

@@ -191,7 +191,7 @@ Storybook:
- sentry-cli releases deploys $VERSION new -e $CI_ENVIRONMENT_NAME - sentry-cli releases deploys $VERSION new -e $CI_ENVIRONMENT_NAME
- sentry-cli releases finalize $VERSION - sentry-cli releases finalize $VERSION
Deploy dev: Dev:
extends: extends:
- .deployJob - .deployJob
environment: environment:
@@ -199,11 +199,15 @@ Deploy dev:
variables: variables:
VM_HOST_NAME: playground.ely.local VM_HOST_NAME: playground.ely.local
VM_DEPLOY_PATH: /srv/dev.account.ely.by/frontend VM_DEPLOY_PATH: /srv/dev.account.ely.by/frontend
only: rules:
refs: - if: '$CI_COMMIT_BRANCH == "master"'
- master when: on_success
- if: '$CI_COMMIT_MESSAGE =~ /\[deploy dev\]/'
when: on_success
# Default:
- when: never
Deploy prod: Prod:
extends: extends:
- .deployJob - .deployJob
stage: deploy stage: deploy
@@ -217,4 +221,5 @@ Deploy prod:
when: never when: never
- if: '$CI_COMMIT_MESSAGE =~ /\[deploy\]/' - if: '$CI_COMMIT_MESSAGE =~ /\[deploy\]/'
when: on_success when: on_success
# Default:
- when: manual - when: manual

126
@types/chalk.d.ts vendored
View File

@@ -7,30 +7,30 @@ declare module 'chalk' {
const enum LevelEnum { const enum LevelEnum {
/** /**
All colors disabled. All colors disabled.
*/ */
None = 0, None = 0,
/** /**
Basic 16 colors support. Basic 16 colors support.
*/ */
Basic = 1, Basic = 1,
/** /**
ANSI 256 colors support. ANSI 256 colors support.
*/ */
Ansi256 = 2, Ansi256 = 2,
/** /**
Truecolor 16 million colors support. Truecolor 16 million colors support.
*/ */
TrueColor = 3, TrueColor = 3,
} }
/** /**
Basic foreground colors. Basic foreground colors.
[More colors here.](https://github.com/chalk/chalk/blob/master/readme.md#256-and-truecolor-color-support) [More colors here.](https://github.com/chalk/chalk/blob/master/readme.md#256-and-truecolor-color-support)
*/ */
type ForegroundColor = type ForegroundColor =
| 'black' | 'black'
| 'red' | 'red'
@@ -53,9 +53,9 @@ declare module 'chalk' {
/** /**
Basic background colors. Basic background colors.
[More colors here.](https://github.com/chalk/chalk/blob/master/readme.md#256-and-truecolor-color-support) [More colors here.](https://github.com/chalk/chalk/blob/master/readme.md#256-and-truecolor-color-support)
*/ */
type BackgroundColor = type BackgroundColor =
| 'bgBlack' | 'bgBlack'
| 'bgRed' | 'bgRed'
@@ -78,9 +78,9 @@ declare module 'chalk' {
/** /**
Basic colors. Basic colors.
[More colors here.](https://github.com/chalk/chalk/blob/master/readme.md#256-and-truecolor-color-support) [More colors here.](https://github.com/chalk/chalk/blob/master/readme.md#256-and-truecolor-color-support)
*/ */
type Color = ForegroundColor | BackgroundColor; type Color = ForegroundColor | BackgroundColor;
type Modifiers = type Modifiers =
@@ -101,59 +101,59 @@ declare module 'chalk' {
/** /**
Specify the color support for Chalk. Specify the color support for Chalk.
By default, color support is automatically detected based on the environment. By default, color support is automatically detected based on the environment.
*/ */
level?: Level; level?: Level;
} }
interface Instance { interface Instance {
/** /**
Return a new Chalk instance. Return a new Chalk instance.
*/ */
new (options?: Options): Chalk; new (options?: Options): Chalk;
} }
/** /**
Detect whether the terminal supports color. Detect whether the terminal supports color.
*/ */
interface ColorSupport { interface ColorSupport {
/** /**
The color level used by Chalk. The color level used by Chalk.
*/ */
level: Level; level: Level;
/** /**
Return whether Chalk supports basic 16 colors. Return whether Chalk supports basic 16 colors.
*/ */
hasBasic: boolean; hasBasic: boolean;
/** /**
Return whether Chalk supports ANSI 256 colors. Return whether Chalk supports ANSI 256 colors.
*/ */
has256: boolean; has256: boolean;
/** /**
Return whether Chalk supports Truecolor 16 million colors. Return whether Chalk supports Truecolor 16 million colors.
*/ */
has16m: boolean; has16m: boolean;
} }
interface ChalkFunction { interface ChalkFunction {
/** /**
Use a template string. Use a template string.
@remarks Template literals are unsupported for nested calls (see [issue #341](https://github.com/chalk/chalk/issues/341)) @remarks Template literals are unsupported for nested calls (see [issue #341](https://github.com/chalk/chalk/issues/341))
@example @example
``` ```
import chalk = require('chalk'); import chalk = require('chalk');
log(chalk` log(chalk`
CPU: {red ${cpu.totalPercent}%} CPU: {red ${cpu.totalPercent}%}
RAM: {green ${ram.used / ram.total * 100}%} RAM: {green ${ram.used / ram.total * 100}%}
DISK: {rgb(255,131,0) ${disk.used / disk.total * 100}%} DISK: {rgb(255,131,0) ${disk.used / disk.total * 100}%}
`); `);
``` ```
*/ */
(text: TemplateStringsArray, ...placeholders: unknown[]): string; (text: TemplateStringsArray, ...placeholders: unknown[]): string;
(...text: unknown[]): string; (...text: unknown[]): string;
@@ -162,182 +162,182 @@ declare module 'chalk' {
interface Chalk extends ChalkFunction { interface Chalk extends ChalkFunction {
/** /**
Return a new Chalk instance. Return a new Chalk instance.
*/ */
Instance: Instance; Instance: Instance;
/** /**
The color support for Chalk. The color support for Chalk.
By default, color support is automatically detected based on the environment. By default, color support is automatically detected based on the environment.
*/ */
level: Level; level: Level;
/** /**
Use HEX value to set text color. Use HEX value to set text color.
@param color - Hexadecimal value representing the desired color. @param color - Hexadecimal value representing the desired color.
@example @example
``` ```
import chalk = require('chalk'); import chalk = require('chalk');
chalk.hex('#DEADED'); chalk.hex('#DEADED');
``` ```
*/ */
hex(color: string): Chalk; hex(color: string): Chalk;
/** /**
Use keyword color value to set text color. Use keyword color value to set text color.
@param color - Keyword value representing the desired color. @param color - Keyword value representing the desired color.
@example @example
``` ```
import chalk = require('chalk'); import chalk = require('chalk');
chalk.keyword('orange'); chalk.keyword('orange');
``` ```
*/ */
keyword(color: string): Chalk; keyword(color: string): Chalk;
/** /**
Use RGB values to set text color. Use RGB values to set text color.
*/ */
rgb(red: number, green: number, blue: number): Chalk; rgb(red: number, green: number, blue: number): Chalk;
/** /**
Use HSL values to set text color. Use HSL values to set text color.
*/ */
hsl(hue: number, saturation: number, lightness: number): Chalk; hsl(hue: number, saturation: number, lightness: number): Chalk;
/** /**
Use HSV values to set text color. Use HSV values to set text color.
*/ */
hsv(hue: number, saturation: number, value: number): Chalk; hsv(hue: number, saturation: number, value: number): Chalk;
/** /**
Use HWB values to set text color. Use HWB values to set text color.
*/ */
hwb(hue: number, whiteness: number, blackness: number): Chalk; 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. 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 30 <= code && code < 38 || 90 <= code && code < 98
For example, 31 for red, 91 for redBright. For example, 31 for red, 91 for redBright.
*/ */
ansi(code: number): Chalk; ansi(code: number): Chalk;
/** /**
Use a [8-bit unsigned number](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) to set text color. Use a [8-bit unsigned number](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) to set text color.
*/ */
ansi256(index: number): Chalk; ansi256(index: number): Chalk;
/** /**
Use HEX value to set background color. Use HEX value to set background color.
@param color - Hexadecimal value representing the desired color. @param color - Hexadecimal value representing the desired color.
@example @example
``` ```
import chalk = require('chalk'); import chalk = require('chalk');
chalk.bgHex('#DEADED'); chalk.bgHex('#DEADED');
``` ```
*/ */
bgHex(color: string): Chalk; bgHex(color: string): Chalk;
/** /**
Use keyword color value to set background color. Use keyword color value to set background color.
@param color - Keyword value representing the desired color. @param color - Keyword value representing the desired color.
@example @example
``` ```
import chalk = require('chalk'); import chalk = require('chalk');
chalk.bgKeyword('orange'); chalk.bgKeyword('orange');
``` ```
*/ */
bgKeyword(color: string): Chalk; bgKeyword(color: string): Chalk;
/** /**
Use RGB values to set background color. Use RGB values to set background color.
*/ */
bgRgb(red: number, green: number, blue: number): Chalk; bgRgb(red: number, green: number, blue: number): Chalk;
/** /**
Use HSL values to set background color. Use HSL values to set background color.
*/ */
bgHsl(hue: number, saturation: number, lightness: number): Chalk; bgHsl(hue: number, saturation: number, lightness: number): Chalk;
/** /**
Use HSV values to set background color. Use HSV values to set background color.
*/ */
bgHsv(hue: number, saturation: number, value: number): Chalk; bgHsv(hue: number, saturation: number, value: number): Chalk;
/** /**
Use HWB values to set background color. Use HWB values to set background color.
*/ */
bgHwb(hue: number, whiteness: number, blackness: number): Chalk; 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. 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 30 <= code && code < 38 || 90 <= code && code < 98
For example, 31 for red, 91 for redBright. For example, 31 for red, 91 for redBright.
Use the foreground code, not the background code (for example, not 41, nor 101). Use the foreground code, not the background code (for example, not 41, nor 101).
*/ */
bgAnsi(code: number): Chalk; bgAnsi(code: number): Chalk;
/** /**
Use a [8-bit unsigned number](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) to set background color. Use a [8-bit unsigned number](https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit) to set background color.
*/ */
bgAnsi256(index: number): Chalk; bgAnsi256(index: number): Chalk;
/** /**
Modifier: Resets the current color chain. Modifier: Resets the current color chain.
*/ */
readonly reset: Chalk; readonly reset: Chalk;
/** /**
Modifier: Make text bold. Modifier: Make text bold.
*/ */
readonly bold: Chalk; readonly bold: Chalk;
/** /**
Modifier: Emitting only a small amount of light. Modifier: Emitting only a small amount of light.
*/ */
readonly dim: Chalk; readonly dim: Chalk;
/** /**
Modifier: Make text italic. (Not widely supported) Modifier: Make text italic. (Not widely supported)
*/ */
readonly italic: Chalk; readonly italic: Chalk;
/** /**
Modifier: Make text underline. (Not widely supported) Modifier: Make text underline. (Not widely supported)
*/ */
readonly underline: Chalk; readonly underline: Chalk;
/** /**
Modifier: Inverse background and foreground colors. Modifier: Inverse background and foreground colors.
*/ */
readonly inverse: Chalk; readonly inverse: Chalk;
/** /**
Modifier: Prints the text, but makes it invisible. Modifier: Prints the text, but makes it invisible.
*/ */
readonly hidden: Chalk; readonly hidden: Chalk;
/** /**
Modifier: Puts a horizontal line through the center of the text. (Not widely supported) Modifier: Puts a horizontal line through the center of the text. (Not widely supported)
*/ */
readonly strikethrough: Chalk; readonly strikethrough: Chalk;
/** /**
Modifier: Prints the text only when Chalk has a color support level > 0. Modifier: Prints the text only when Chalk has a color support level > 0.
Can be useful for things that are purely cosmetic. Can be useful for things that are purely cosmetic.
*/ */
readonly visible: Chalk; readonly visible: Chalk;
readonly black: Chalk; readonly black: Chalk;
@@ -403,7 +403,7 @@ declare module 'chalk' {
Call the last one as a method with a string argument. 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. 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`. This simply means that `chalk.red.yellow.green` is equivalent to `chalk.green`.
*/ */
const chalk: chalk.Chalk & const chalk: chalk.Chalk &
chalk.ChalkFunction & { chalk.ChalkFunction & {
supportsColor: chalk.ColorSupport | false; supportsColor: chalk.ColorSupport | false;

View File

@@ -2,16 +2,29 @@
// @ts-nocheck // @ts-nocheck
module.exports = function (api) { module.exports = function (api) {
const env = api.env(); 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 { return {
presets: [ presets: [
[
'@babel/preset-typescript',
{
allowDeclareFields: true,
},
],
'@babel/preset-react', '@babel/preset-react',
[ [
'@babel/preset-env', '@babel/preset-env',
@@ -23,11 +36,24 @@ module.exports = function (api) {
modules: 'commonjs', 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: [ plugins: [
'@babel/plugin-syntax-dynamic-import', '@babel/plugin-syntax-dynamic-import',
'@babel/plugin-proposal-function-bind', '@babel/plugin-proposal-function-bind',
'@babel/plugin-proposal-class-properties',
'@babel/plugin-proposal-optional-chaining', '@babel/plugin-proposal-optional-chaining',
'@babel/plugin-transform-runtime', '@babel/plugin-transform-runtime',
[ [
@@ -36,25 +62,14 @@ module.exports = function (api) {
removePrefix: 'packages.app', removePrefix: 'packages.app',
messagesDir: './build/messages/', messagesDir: './build/messages/',
useKey: true, useKey: true,
removeDefaultMessage: env === 'production', removeDefaultMessage: isProduction,
}, },
], ],
], ],
env: { env: {
webpack: { browser: browserEnv,
plugins: ['react-hot-loader/babel'], 'browser-development': browserEnv,
presets: [ 'browser-production': browserEnv,
[
'@babel/preset-env',
{
ignoreBrowserslistConfig: false,
modules: false,
useBuiltIns: 'usage', // or "entry"
corejs: 3,
},
],
],
},
}, },
}; };
}; };

View File

@@ -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);

View 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;

View File

@@ -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;
}
}

View File

@@ -7,10 +7,11 @@ import * as authentication from 'app/services/api/authentication';
import { authenticate, revoke, logoutAll, logoutStrangers } from 'app/components/accounts/actions'; import { authenticate, revoke, logoutAll, logoutStrangers } from 'app/components/accounts/actions';
import { add, activate, remove, reset } from 'app/components/accounts/actions/pure-actions'; import { add, activate, remove, reset } from 'app/components/accounts/actions/pure-actions';
import { updateUser, setUser } from 'app/components/user/actions'; import { updateUser, setUser } from 'app/components/user/actions';
import { setLogin, setAccountSwitcher } from 'app/components/auth/actions'; import { setLogin } from 'app/components/auth/actions';
import { Dispatch, State as RootState } from 'app/types'; import { Dispatch, State as RootState } from 'app/types';
import { Account } from './reducer'; import { Account } from './reducer';
import { User } from 'app/components/user';
jest.mock('app/i18n', () => ({ jest.mock('app/i18n', () => ({
en: { en: {
@@ -32,19 +33,21 @@ jest.mock('app/i18n', () => ({
const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJlbHl8MSJ9.pRJ7vakt2eIscjqwG__KhSxKb3qwGsdBBeDbBffJs_I'; const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJlbHl8MSJ9.pRJ7vakt2eIscjqwG__KhSxKb3qwGsdBBeDbBffJs_I';
const legacyToken = 'eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOjF9.cRF-sQNrwWQ94xCb3vWioVdjxAZeefEE7GMGwh7708o'; const legacyToken = 'eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOjF9.cRF-sQNrwWQ94xCb3vWioVdjxAZeefEE7GMGwh7708o';
const account = { const account: Account = {
id: 1, id: 1,
username: 'username', username: 'username',
email: 'email@test.com', email: 'email@test.com',
token, token,
refreshToken: 'bar', refreshToken: 'bar',
isDeleted: false,
}; };
const user = { const user: Partial<User> = {
id: 1, id: 1,
username: 'username', username: 'username',
email: 'email@test.com', email: 'email@test.com',
lang: 'be', lang: 'be',
isDeleted: false,
}; };
describe('components/accounts/actions', () => { 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', () => { describe('when one account available', () => {

View File

@@ -1,7 +1,7 @@
import { getJwtPayloads } from 'app/functions'; import { getJwtPayloads } from 'app/functions';
import { sessionStorage } from 'app/services/localStorage'; import { sessionStorage } from 'app/services/localStorage';
import { validateToken, requestToken, logout } from 'app/services/api/authentication'; import { validateToken, requestToken, logout } from 'app/services/api/authentication';
import { relogin as navigateToLogin, setAccountSwitcher } from 'app/components/auth/actions'; import { relogin as navigateToLogin } from 'app/components/auth/actions';
import { updateUser, setGuest } from 'app/components/user/actions'; import { updateUser, setGuest } from 'app/components/user/actions';
import { setLocale } from 'app/components/i18n/actions'; import { setLocale } from 'app/components/i18n/actions';
import logger from 'app/services/logger'; import logger from 'app/services/logger';
@@ -12,13 +12,6 @@ import { add, remove, activate, reset, updateToken } from './actions/pure-action
export { updateToken, activate, remove }; export { updateToken, activate, remove };
/**
* @param {Account|object} account
* @param {string} account.token
* @param {string} account.refreshToken
*
* @returns {Function}
*/
export function authenticate( export function authenticate(
account: account:
| Account | Account
@@ -53,13 +46,13 @@ export function authenticate(
token, token,
refreshToken, refreshToken,
); );
const { auth } = getState();
const newAccount: Account = { const newAccount: Account = {
id: user.id, id: user.id,
username: user.username, username: user.username,
email: user.email, email: user.email,
token: newToken, token: newToken,
refreshToken: newRefreshToken, refreshToken: newRefreshToken,
isDeleted: user.isDeleted,
}; };
dispatch(add(newAccount)); dispatch(add(newAccount));
dispatch(activate(newAccount)); dispatch(activate(newAccount));
@@ -78,14 +71,6 @@ export function authenticate(
sessionStorage.setItem(`stranger${newAccount.id}`, '1'); 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)); await dispatch(setLocale(user.lang));
return newAccount; return newAccount;

View File

@@ -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;

View File

@@ -1,5 +1,4 @@
import { State, Account as AccountType } from './reducer'; import { State, Account as AccountType } from './reducer';
export { default as AccountSwitcher } from './AccountSwitcher';
export type AccountsState = State; export type AccountsState = State;
export type Account = AccountType; export type Account = AccountType;

View File

@@ -1,7 +1,7 @@
import expect from 'app/test/unexpected'; import expect from 'app/test/unexpected';
import { updateToken } from './actions'; 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 { AccountsState } from './index';
import accounts, { Account } from './reducer'; import accounts, { Account } from './reducer';
@@ -10,7 +10,9 @@ const account: Account = {
username: 'username', username: 'username',
email: 'email@test.com', email: 'email@test.com',
token: 'foo', token: 'foo',
} as Account; refreshToken: '',
isDeleted: false,
};
describe('Accounts reducer', () => { describe('Accounts reducer', () => {
let initial: AccountsState; 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,
},
],
});
});
});
}); });

View File

@@ -6,6 +6,7 @@ export type Account = {
email: string; email: string;
token: string; token: string;
refreshToken: string | null; refreshToken: string | null;
isDeleted: boolean;
}; };
export type State = { export type State = {
@@ -23,6 +24,23 @@ export function getAvailableAccounts(state: { accounts: State }): Array<Account>
return state.accounts.available; 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( export default function accounts(
state: State = { state: State = {
active: null, active: null,
@@ -90,27 +108,33 @@ export default function accounts(
} }
case 'accounts:updateToken': { case 'accounts:updateToken': {
if (typeof action.payload !== 'string') { return partiallyUpdateActiveAccount(state, {
throw new Error('payload must be a jwt token'); token: action.payload,
} });
}
const { payload } = action; case 'accounts:markAsDeleted': {
return partiallyUpdateActiveAccount(state, {
return { isDeleted: action.payload,
...state, });
available: state.available.map((account) => {
if (account.id === state.active) {
return {
...account,
token: payload,
};
}
return { ...account };
}),
};
} }
} }
return state; 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 };
}),
};
}

View 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>
);
};

View File

@@ -15,7 +15,7 @@ class BaseAuthBody extends React.Component<
RouteComponentProps<Record<string, any>> RouteComponentProps<Record<string, any>>
> { > {
static contextType = Context; static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>; declare context: React.ContextType<typeof Context>;
prevErrors: AuthContext['auth']['error']; prevErrors: AuthContext['auth']['error'];
autoFocusField: string | null = ''; autoFocusField: string | null = '';

View File

@@ -8,8 +8,8 @@ export interface AuthContext {
user: User; user: User;
requestRedraw: () => Promise<void>; requestRedraw: () => Promise<void>;
clearErrors: () => void; clearErrors: () => void;
resolve: (payload: { [key: string]: any } | undefined) => void; resolve: (payload: Record<string, any> | undefined) => Promise<any> | void;
reject: (payload: { [key: string]: any } | undefined) => void; reject: (payload: Record<string, any> | undefined) => Promise<any> | void;
} }
const Context = React.createContext<AuthContext>({ const Context = React.createContext<AuthContext>({

View File

@@ -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} />);

View File

@@ -7,6 +7,7 @@ import Body from './AcceptRulesBody';
const messages = defineMessages({ const messages = defineMessages({
title: 'User Agreement', title: 'User Agreement',
declineAndLogout: 'Decline and logout', declineAndLogout: 'Decline and logout',
deleteAccount: 'Delete account',
}); });
export default factory({ export default factory({
@@ -17,7 +18,13 @@ export default factory({
autoFocus: true, autoFocus: true,
children: <Message key="accept" defaultMessage="Accept" />, children: <Message key="accept" defaultMessage="Accept" />,
}, },
links: { links: [
label: messages.declineAndLogout, {
}, label: messages.declineAndLogout,
},
{
label: messages.deleteAccount,
payload: { deleteAccount: true },
},
],
}); });

View File

@@ -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} />);

View File

@@ -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;

View File

@@ -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} />);

View File

@@ -2,12 +2,23 @@ import React from 'react';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
import { connect } from 'app/functions';
import BaseAuthBody from 'app/components/auth/BaseAuthBody'; import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import { AccountSwitcher } from 'app/components/accounts'; import { getSortedAccounts } from 'app/components/accounts/reducer';
import { Account } from 'app/components/accounts/reducer'; import type { Account } from 'app/components/accounts';
import AccountSwitcher from './AccountSwitcher';
import styles from './chooseAccount.scss'; import styles from './chooseAccount.scss';
// I can't connect the ChooseAccountBody component with redux's "connect" function
// to get accounts list because it will break the TransitionMotion animation implementation.
//
// So to provide accounts list to the component, I'll create connected version of
// the composes with already provided accounts list
const ConnectedAccountSwitcher = connect((state) => ({
accounts: getSortedAccounts(state),
}))(AccountSwitcher);
export default class ChooseAccountBody extends BaseAuthBody { export default class ChooseAccountBody extends BaseAuthBody {
static displayName = 'ChooseAccountBody'; static displayName = 'ChooseAccountBody';
static panelId = 'chooseAccount'; static panelId = 'chooseAccount';
@@ -29,28 +40,21 @@ export default class ChooseAccountBody extends BaseAuthBody {
}} }}
/> />
) : ( ) : (
<div className={styles.description}> <Message
<Message key="pleaseChooseAccount"
key="pleaseChooseAccount" defaultMessage="Please select an account you're willing to use"
defaultMessage="Please select an account you're willing to use" />
/>
</div>
)} )}
</div> </div>
<div className={styles.accountSwitcherContainer}> <div className={styles.accountSwitcherContainer}>
<AccountSwitcher <ConnectedAccountSwitcher onAccountClick={this.onSwitch} />
allowAdd={false}
allowLogout={false}
highlightActiveAccount={false}
onSwitch={this.onSwitch}
/>
</div> </div>
</div> </div>
); );
} }
onSwitch = (account: Account): void => { onSwitch = (account: Account): Promise<void> => {
this.context.resolve(account); return Promise.resolve(this.context.resolve(account));
}; };
} }

View File

@@ -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;
}

View File

@@ -28,7 +28,7 @@ const FooterMenu: ComponentType = () => {
<Message key="rules" defaultMessage="Rules" /> <Message key="rules" defaultMessage="Rules" />
</Link> </Link>
{''} {''}
<ContactLink className={styles.footerItem}> <ContactLink className={styles.footerItem}>
<Message key="contactUs" defaultMessage="Contact Us" /> <Message key="contactUs" defaultMessage="Contact Us" />
@@ -40,7 +40,7 @@ const FooterMenu: ComponentType = () => {
<Message key="forDevelopers" defaultMessage="For developers" /> <Message key="forDevelopers" defaultMessage="For developers" />
</Link> </Link>
{''} {''}
<a href="#" className={styles.footerItem} onClick={createPopupHandler(SourceCode)}> <a href="#" className={styles.footerItem} onClick={createPopupHandler(SourceCode)}>
<Message key="sourceCode" defaultMessage="Source code" /> <Message key="sourceCode" defaultMessage="Source code" />

View File

@@ -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; return require('./flags/unknown.svg').default;
} }

View 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>
));

View 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;

View File

@@ -22,6 +22,7 @@ storiesOf('Components/Profile', module).add('Profile', () => (
hasMojangUsernameCollision: true, hasMojangUsernameCollision: true,
isActive: true, isActive: true,
isGuest: false, isGuest: false,
isDeleted: false,
isOtpEnabled: true, isOtpEnabled: true,
lang: 'unknown', lang: 'unknown',
passwordChangedAt: 1595328712, passwordChangedAt: 1595328712,

View File

@@ -2,8 +2,10 @@ import React, { ComponentType, useCallback, useRef } from 'react';
import { FormattedMessage as Message } from 'react-intl'; import { FormattedMessage as Message } from 'react-intl';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import { ChangeLanguageLink } from 'app/components/languageSwitcher'; import { ChangeLanguageLink } from 'app/components/languageSwitcher';
import { RelativeTime } from 'app/components/ui'; import { RelativeTime } from 'app/components/ui';
import { Button } from 'app/components/ui/form';
import { User } from 'app/components/user'; import { User } from 'app/components/user';
import RulesPage from 'app/pages/rules/RulesPage'; import RulesPage from 'app/pages/rules/RulesPage';
@@ -61,7 +63,7 @@ const Profile: ComponentType<Props> = ({ user, activeLocale }) => {
</div> </div>
<div className={styles.formColumn}> <div className={styles.formColumn}>
<div className={profileForm.form}> <div className={styles.profilePanel}>
<div className={styles.item}> <div className={styles.item}>
<h3 className={profileForm.title}> <h3 className={profileForm.title}>
<Message key="personalData" defaultMessage="Personal data" /> <Message key="personalData" defaultMessage="Personal data" />
@@ -108,20 +110,6 @@ const Profile: ComponentType<Props> = ({ user, activeLocale }) => {
value={user.email} 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 <ProfileField
label={<Message key="siteLanguage" defaultMessage="Site language:" />} label={<Message key="siteLanguage" defaultMessage="Site language:" />}
value={<ChangeLanguageLink />} value={<ChangeLanguageLink />}
@@ -150,18 +138,6 @@ const Profile: ComponentType<Props> = ({ user, activeLocale }) => {
} }
/> />
<ProfileField
link="/profile/mfa"
label={<Message key="twoFactorAuth" defaultMessage="Twofactor auth:" />}
value={
user.isOtpEnabled ? (
<Message key="enabled" defaultMessage="Enabled" />
) : (
<Message key="disabled" defaultMessage="Disabled" />
)
}
/>
<ProfileField <ProfileField
label={<Message key="uuid" defaultMessage="UUID:" />} label={<Message key="uuid" defaultMessage="UUID:" />}
value={ value={
@@ -175,6 +151,61 @@ const Profile: ComponentType<Props> = ({ user, activeLocale }) => {
} }
/> />
</div> </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="Twofactor 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> </div>
</div> </div>

View File

@@ -10,7 +10,7 @@ function ProfileField({
link, link,
onChange, onChange,
}: { }: {
label: React.ReactNode; label?: React.ReactNode;
link?: string; link?: string;
onChange?: () => void; onChange?: () => void;
value: React.ReactNode; value: React.ReactNode;
@@ -29,7 +29,7 @@ function ProfileField({
return ( return (
<div className={styles.paramItem} data-testid="profile-item"> <div className={styles.paramItem} data-testid="profile-item">
<div className={styles.paramRow}> <div className={styles.paramRow}>
<div className={styles.paramName}>{label}</div> {label ? <div className={styles.paramName}>{label}</div> : ''}
<div className={styles.paramValue}>{value}</div> <div className={styles.paramValue}>{value}</div>
{Action && ( {Action && (

View 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;
}
}

View File

@@ -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>
));

View 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;

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1 @@
export { default } from './DeleteAccount';

View File

@@ -17,7 +17,7 @@ export default class MfaDisable extends React.Component<
} }
> { > {
static contextType = Context; static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>; declare context: React.ContextType<typeof Context>;
state = { state = {
showForm: false, showForm: false,

View File

@@ -41,7 +41,7 @@ interface State {
export default class MfaEnable extends React.PureComponent<Props, State> { export default class MfaEnable extends React.PureComponent<Props, State> {
static contextType = Context; static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>; declare context: React.ContextType<typeof Context>;
static defaultProps = { static defaultProps = {
confirmationForm: new FormModel(), confirmationForm: new FormModel(),

View File

@@ -18,7 +18,7 @@ const MfaStatus: ComponentType<Props> = ({ onProceed }) => (
<div className={mfaStyles.bigIcon}> <div className={mfaStyles.bigIcon}>
<span className={icons.lock} /> <span className={icons.lock} />
</div> </div>
<p className={`${styles.description} ${mfaStyles.mfaTitle}`}> <p className={mfaStyles.mfaTitle}>
<Message <Message
key="mfaEnabledForYourAcc" key="mfaEnabledForYourAcc"
defaultMessage="Twofactor authentication for your account is active now" defaultMessage="Twofactor authentication for your account is active now"

View File

@@ -49,7 +49,7 @@ storiesOf('Components/Profile/MultiFactorAuth', module)
)) ))
.add('Enabled', () => ( .add('Enabled', () => (
<MultiFactorAuth <MultiFactorAuth
isMfaEnabled={true} isMfaEnabled
step={0} step={0}
onSubmit={(form, sendData) => { onSubmit={(form, sendData) => {
action('onSubmit')(form, sendData); action('onSubmit')(form, sendData);

View File

@@ -2,9 +2,8 @@
@import '~app/components/ui/fonts.scss'; @import '~app/components/ui/fonts.scss';
.mfaTitle { .mfaTitle {
font-size: 18px; composes: sectionTitle from '~app/components/profile/profileForm.scss';
font-family: $font-family-title;
line-height: 1.2;
text-align: center; text-align: center;
margin-left: 17%; margin-left: 17%;

View File

@@ -10,7 +10,6 @@ $formColumnWidth: 416px;
.formColumn { .formColumn {
width: $formColumnWidth; width: $formColumnWidth;
min-width: $formColumnWidth; // Чтобы flex не ужимал блок, несмотря на фикс ширину выше min-width: $formColumnWidth; // Чтобы flex не ужимал блок, несмотря на фикс ширину выше
border-bottom: 10px solid #ddd8ce;
} }
.descriptionColumn { .descriptionColumn {
@@ -30,6 +29,13 @@ $formColumnWidth: 416px;
color: #9a9a9a; color: #9a9a9a;
} }
.profilePanel {
composes: form from '~app/components/profile/profileForm.scss';
margin-bottom: 30px;
border-bottom: 10px solid #ddd8ce;
}
.item { .item {
padding: 30px; padding: 30px;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;

View File

@@ -84,6 +84,20 @@
margin-top: 25px; 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 { .stepper {
width: 35%; width: 35%;
margin: 0 auto; margin: 0 auto;

View 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;

View File

@@ -1,7 +1,8 @@
import React, { InputHTMLAttributes, MouseEventHandler } from 'react'; import React, { InputHTMLAttributes, MouseEventHandler } from 'react';
import ReactDOM from 'react-dom';
import { MessageDescriptor } from 'react-intl'; import { MessageDescriptor } from 'react-intl';
import ClickAwayListener from 'react-click-away-listener';
import clsx from 'clsx'; import clsx from 'clsx';
import { COLOR_GREEN, Color } from 'app/components/ui'; import { COLOR_GREEN, Color } from 'app/components/ui';
import styles from './dropdown.scss'; import styles from './dropdown.scss';
@@ -12,7 +13,7 @@ type ItemLabel = I18nString | React.ReactElement;
interface Props extends InputHTMLAttributes<HTMLInputElement> { interface Props extends InputHTMLAttributes<HTMLInputElement> {
label: I18nString; label: I18nString;
items: { [value: string]: ItemLabel }; items: Record<string, ItemLabel>;
block?: boolean; block?: boolean;
color: Color; color: Color;
} }
@@ -37,18 +38,6 @@ export default class Dropdown extends FormInputComponent<Props, State> {
activeItem: null, activeItem: null,
}; };
componentDidMount() {
// listen to capturing phase to ensure, that our event handler will be
// called before all other
// @ts-ignore
document.addEventListener('click', this.onBodyClick, true);
}
componentWillUnmount() {
// @ts-ignore
document.removeEventListener('click', this.onBodyClick);
}
render() { render() {
const { color, block, items, ...restProps } = this.props; const { color, block, items, ...restProps } = this.props;
const { isActive } = this.state; const { isActive } = this.state;
@@ -59,7 +48,7 @@ export default class Dropdown extends FormInputComponent<Props, State> {
const label = React.isValidElement(activeItem.label) ? activeItem.label : this.formatMessage(activeItem.label); const label = React.isValidElement(activeItem.label) ? activeItem.label : this.formatMessage(activeItem.label);
return ( return (
<div> <ClickAwayListener onClickAway={this.onCloseClick}>
<div <div
className={clsx(styles[color], { className={clsx(styles[color], {
[styles.block]: block, [styles.block]: block,
@@ -84,7 +73,7 @@ export default class Dropdown extends FormInputComponent<Props, State> {
</div> </div>
{this.renderError()} {this.renderError()}
</div> </ClickAwayListener>
); );
} }
@@ -137,17 +126,9 @@ export default class Dropdown extends FormInputComponent<Props, State> {
this.toggle(); this.toggle();
}; };
onBodyClick: MouseEventHandler = (event) => { onCloseClick = () => {
if (this.state.isActive) { if (this.state.isActive) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion this.toggle();
const el = ReactDOM.findDOMNode(this)!;
if (!el.contains(event.target as HTMLElement) && el !== event.target) {
event.preventDefault();
event.stopPropagation();
this.toggle();
}
} }
}; };
} }

View File

@@ -30,3 +30,4 @@ export const SKIN_LIGHT: Skin = 'light';
export const skins: Array<Skin> = [SKIN_DARK, SKIN_LIGHT]; export const skins: Array<Skin> = [SKIN_DARK, SKIN_LIGHT];
export { default as RelativeTime } from './RelativeTime'; export { default as RelativeTime } from './RelativeTime';
export { default as PseudoAvatar } from './PseudoAvatar';

View File

@@ -9,10 +9,11 @@ import styles from './componentLoader.scss';
interface Props { interface Props {
skin?: Skin; skin?: Skin;
className?: string;
} }
const ComponentLoader: ComponentType<Props> = ({ skin = 'dark' }) => ( const ComponentLoader: ComponentType<Props> = ({ skin = 'dark', className }) => (
<div className={clsx(styles.componentLoader, styles[`${skin}ComponentLoader`])}> <div className={clsx(styles.componentLoader, styles[`${skin}ComponentLoader`], className)}>
<div className={styles.spins}> <div className={styles.spins}>
{new Array(5).fill(0).map((_, index) => ( {new Array(5).fill(0).map((_, index) => (
<div className={clsx(styles.spin, styles[`spin${index}`])} key={index} /> <div className={clsx(styles.spin, styles[`spin${index}`])} key={index} />

View File

@@ -1,41 +1,31 @@
@import '~app/components/ui/colors.scss'; @import '~app/components/ui/colors.scss';
.componentLoader { .componentLoader {
width: 100%;
text-align: center; text-align: center;
font-size: 20px;
} }
.spins { .spins {
height: 40px; height: 2em;
display: flex;
flex-shrink: 1;
flex-basis: 0;
flex-direction: row;
} }
.spin { .spin {
height: 20px; height: 1em;
width: 20px; width: 1em;
display: inline-block; display: inline-block;
margin: 10px 2px; margin: 0.5em 0.1em;
opacity: 0; opacity: 0;
animation: loaderAnimation 1s infinite; animation: loaderAnimation 1s infinite;
} }
.spin1 { @for $i from 0 to 5 {
animation-delay: 0s; .spin#{$i} {
} animation-delay: 0.1s * $i;
}
.spin2 {
animation-delay: 0.1s;
}
.spin3 {
animation-delay: 0.2s;
}
.spin4 {
animation-delay: 0.3s;
}
.spin5 {
animation-delay: 0.4s;
} }
/** /**

View 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);
}

View File

@@ -10,6 +10,7 @@ export interface User {
lang: string; lang: string;
isGuest: boolean; isGuest: boolean;
isActive: boolean; isActive: boolean;
isDeleted: boolean;
isOtpEnabled: boolean; isOtpEnabled: boolean;
passwordChangedAt: number; passwordChangedAt: number;
hasMojangUsernameCollision: boolean; hasMojangUsernameCollision: boolean;
@@ -31,6 +32,7 @@ const defaults: State = {
avatar: '', avatar: '',
lang: '', lang: '',
isActive: false, isActive: false,
isDeleted: false,
isOtpEnabled: false, isOtpEnabled: false,
shouldAcceptRules: false, // whether user need to review updated rules shouldAcceptRules: false, // whether user need to review updated rules
passwordChangedAt: 0, passwordChangedAt: 0,

View 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;

View 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)}
/>
));

View File

@@ -1,120 +1,64 @@
import React, { MouseEventHandler } from 'react'; import React, { ComponentType, useCallback, useState } from 'react';
import clsx from 'clsx'; import clsx from 'clsx';
import { AccountSwitcher } from 'app/components/accounts'; import ClickAwayListener from 'react-click-away-listener';
import { Account } from 'app/components/accounts';
import AccountSwitcher from './AccountSwitcher';
import styles from './loggedInPanel.scss'; import styles from './loggedInPanel.scss';
export default class LoggedInPanel extends React.Component< interface Props {
{ activeAccount: Account;
username: string; accounts: ReadonlyArray<Account>;
}, onSwitchAccount?: (account: Account) => Promise<any>;
{ onRemoveAccount?: (account: Account) => Promise<any>;
isAccountSwitcherActive: boolean; }
}
> {
state = {
isAccountSwitcherActive: false,
};
_isMounted: boolean = false; const LoggedInPanel: ComponentType<Props> = ({ activeAccount, accounts, onSwitchAccount, onRemoveAccount }) => {
el: HTMLElement | null; const [isAccountSwitcherActive, setAccountSwitcherState] = useState(false);
const hideAccountSwitcher = useCallback(() => setAccountSwitcherState(false), []);
const onAccountClick = useCallback(
async (account: Account) => {
if (onSwitchAccount) {
await onSwitchAccount(account);
}
componentDidMount() { setAccountSwitcherState(false);
if (window.document) { },
// @ts-ignore [onSwitchAccount],
window.document.addEventListener('click', this.onBodyClick); );
}
this._isMounted = true; return (
} <div className={styles.loggedInPanel}>
<div
componentWillUnmount() { className={clsx(styles.activeAccount, {
if (window.document) { [styles.activeAccountExpanded]: isAccountSwitcherActive,
// @ts-ignore })}
window.document.removeEventListener('click', this.onBodyClick); >
} <ClickAwayListener onClickAway={hideAccountSwitcher}>
<button
this._isMounted = false; className={styles.activeAccountButton}
} onClick={setAccountSwitcherState.bind(null, !isAccountSwitcherActive)}
>
render() {
const { username } = this.props;
const { isAccountSwitcherActive } = this.state;
return (
<div ref={(el) => (this.el = el)} className={clsx(styles.loggedInPanel)}>
<div
className={clsx(styles.activeAccount, {
[styles.activeAccountExpanded]: isAccountSwitcherActive,
})}
>
<button className={styles.activeAccountButton} onClick={this.onExpandAccountSwitcher}>
<span className={styles.userIcon} /> <span className={styles.userIcon} />
<span className={styles.userName}>{username}</span> <span className={styles.userName}>{activeAccount.username}</span>
<span className={styles.expandIcon} /> <span className={styles.expandIcon} />
</button> </button>
<div className={clsx(styles.accountSwitcherContainer)}> <div className={styles.accountSwitcherContainer}>
<AccountSwitcher skin="light" onAfterAction={this.onToggleAccountSwitcher} /> <AccountSwitcher
activeAccount={activeAccount}
accounts={accounts}
onAccountClick={onAccountClick}
onRemoveClick={onRemoveAccount}
onLoginClick={hideAccountSwitcher}
/>
</div> </div>
</div> </ClickAwayListener>
</div> </div>
); </div>
}
toggleAccountSwitcher = () =>
this._isMounted &&
this.setState({
isAccountSwitcherActive: !this.state.isAccountSwitcherActive,
});
onToggleAccountSwitcher = () => {
this.toggleAccountSwitcher();
};
onExpandAccountSwitcher = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
this.toggleAccountSwitcher();
};
onBodyClick = createOnOutsideComponentClickHandler(
() => this.el,
() => this.state.isAccountSwitcherActive && this._isMounted,
() => this.toggleAccountSwitcher(),
); );
} };
/** export default LoggedInPanel;
* Creates an event handling function to handle clicks outside the component
*
* The handler will check if current click was outside container el and if so
* and component isActive, it will call the callback
*
* @param {Function} getEl - the function, that returns reference to container el
* @param {Function} isActive - whether the component is active and callback may be called
* @param {Function} callback - the callback to call, when there was a click outside el
*
* @returns {Function}
*/
function createOnOutsideComponentClickHandler(
getEl: () => HTMLElement | null,
isActive: () => boolean,
callback: () => void,
): MouseEventHandler {
// TODO: we have the same logic in LangMenu
// Probably we should decouple this into some helper function
// TODO: the name of function may be better...
return (event) => {
const el = getEl();
if (isActive() && el) {
if (!el.contains(event.target as HTMLElement) && el !== event.target) {
event.preventDefault();
// add a small delay for the case someone have alredy called toggle
setTimeout(() => isActive() && callback(), 0);
}
}
};
}

View File

@@ -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>
);
}
}

View 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;
}
}

View File

@@ -1,3 +0,0 @@
.userbar {
text-align: right;
}

View File

@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-var-requires */
/** /**
* Returns the content to be displayed on first render * Returns the content to be displayed on first render
*/ */

View File

@@ -18,6 +18,7 @@
"raf": "^3.4.1", "raf": "^3.4.1",
"raven-js": "^3.27.0", "raven-js": "^3.27.0",
"react": "^16.13.1", "react": "^16.13.1",
"react-click-away-listener": "^1.4.3",
"react-dom": "^16.13.1", "react-dom": "^16.13.1",
"react-helmet-async": "^1.0.6", "react-helmet-async": "^1.0.6",
"react-intl": "^4.5.7", "react-intl": "^4.5.7",

View 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;

View File

@@ -18,7 +18,7 @@ interface Props extends RouteComponentProps<RouteParams> {
class ChangeEmailPage extends React.Component<Props> { class ChangeEmailPage extends React.Component<Props> {
static contextType = Context; static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>; declare context: React.ContextType<typeof Context>;
render() { render() {
const { step = 'step1', code } = this.props.match.params; const { step = 'step1', code } = this.props.match.params;

View File

@@ -14,7 +14,7 @@ interface Props {
class ChangePasswordPage extends React.Component<Props> { class ChangePasswordPage extends React.Component<Props> {
static contextType = Context; static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>; declare context: React.ContextType<typeof Context>;
form = new FormModel(); form = new FormModel();

View File

@@ -14,7 +14,7 @@ type Props = {
class ChangeUsernamePage extends React.Component<Props> { class ChangeUsernamePage extends React.Component<Props> {
static contextType = Context; static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>; declare context: React.ContextType<typeof Context>;
form = new FormModel(); form = new FormModel();

View 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;

View File

@@ -16,7 +16,7 @@ interface Props
class MultiFactorAuthPage extends React.Component<Props> { class MultiFactorAuthPage extends React.Component<Props> {
static contextType = Context; static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>; declare context: React.ContextType<typeof Context>;
render() { render() {
const { const {

View File

@@ -11,7 +11,7 @@ import { browserHistory } from 'app/services/history';
import { FooterMenu } from 'app/components/footerMenu'; import { FooterMenu } from 'app/components/footerMenu';
import { FormModel } from 'app/components/ui/form'; import { FormModel } from 'app/components/ui/form';
import { Provider } from 'app/components/profile/Context'; 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'; import styles from './profile.scss';
@@ -20,14 +20,16 @@ import ChangePasswordPage from 'app/pages/profile/ChangePasswordPage';
import ChangeUsernamePage from 'app/pages/profile/ChangeUsernamePage'; import ChangeUsernamePage from 'app/pages/profile/ChangeUsernamePage';
import ChangeEmailPage from 'app/pages/profile/ChangeEmailPage'; import ChangeEmailPage from 'app/pages/profile/ChangeEmailPage';
import MultiFactorAuthPage from 'app/pages/profile/MultiFactorAuthPage'; import MultiFactorAuthPage from 'app/pages/profile/MultiFactorAuthPage';
import DeleteAccountPage from 'app/pages/profile/DeleteAccountPage';
import AccountDeletedPage from 'app/pages/profile/AccountDeletedPage';
interface Props { interface Props {
userId: number; user: User;
onSubmit: (options: { form: FormModel; sendData: () => Promise<any> }) => Promise<void>; onSubmit: (options: { form: FormModel; sendData: () => Promise<any> }) => Promise<void>;
refreshUserData: () => Promise<any>; refreshUserData: () => Promise<any>;
} }
const ProfileController: ComponentType<Props> = ({ userId, onSubmit, refreshUserData }) => { const ProfileController: ComponentType<Props> = ({ user, onSubmit, refreshUserData }) => {
const goToProfile = useCallback(async () => { const goToProfile = useCallback(async () => {
await refreshUserData(); await refreshUserData();
@@ -38,23 +40,29 @@ const ProfileController: ComponentType<Props> = ({ userId, onSubmit, refreshUser
<div className={styles.container}> <div className={styles.container}>
<Provider <Provider
value={{ value={{
userId, userId: user.id!,
onSubmit, onSubmit,
goToProfile, goToProfile,
}} }}
> >
<React.Suspense fallback={<ComponentLoader />}> {user.isDeleted ? (
<Switch>
<Route path="/" exact component={AccountDeletedPage} />
<Redirect to="/" />
</Switch>
) : (
<Switch> <Switch>
<Route path="/profile/mfa/step:step([1-3])" component={MultiFactorAuthPage} /> <Route path="/profile/mfa/step:step([1-3])" component={MultiFactorAuthPage} />
<Route path="/profile/mfa" exact component={MultiFactorAuthPage} /> <Route path="/profile/mfa" exact component={MultiFactorAuthPage} />
<Route path="/profile/change-password" exact component={ChangePasswordPage} /> <Route path="/profile/change-password" exact component={ChangePasswordPage} />
<Route path="/profile/change-username" exact component={ChangeUsernamePage} /> <Route path="/profile/change-username" exact component={ChangeUsernamePage} />
<Route path="/profile/change-email/:step?/:code?" component={ChangeEmailPage} /> <Route path="/profile/change-email/:step?/:code?" component={ChangeEmailPage} />
<Route path="/profile/delete" component={DeleteAccountPage} />
<Route path="/profile" exact component={Profile} /> <Route path="/profile" exact component={Profile} />
<Route path="/" exact component={Profile} /> <Route path="/" exact component={Profile} />
<Redirect to="/404" /> <Redirect to="/404" />
</Switch> </Switch>
</React.Suspense> )}
<div className={styles.footer}> <div className={styles.footer}>
<FooterMenu /> <FooterMenu />
@@ -66,7 +74,7 @@ const ProfileController: ComponentType<Props> = ({ userId, onSubmit, refreshUser
export default connect( export default connect(
(state) => ({ (state) => ({
userId: state.user.id!, user: state.user,
}), }),
{ {
refreshUserData, refreshUserData,

View File

@@ -1,5 +1,5 @@
.container { .container {
padding: 55px 10px 65px; // 65px for footer padding: 55px 10px 80px; // 80px for footer
} }
.footer { .footer {

View File

@@ -1,7 +1,5 @@
import React from 'react'; import React from 'react';
import { withRouter } from 'react-router-dom'; import { Route, Switch } from 'react-router-dom';
import { FormattedMessage as Message } from 'react-intl';
import { Route, Link, Switch } from 'react-router-dom';
import { Helmet } from 'react-helmet-async'; import { Helmet } from 'react-helmet-async';
import clsx from 'clsx'; import clsx from 'clsx';
@@ -10,21 +8,20 @@ import { resetAuth } from 'app/components/auth/actions';
import { ScrollIntoView } from 'app/components/ui/scroll'; import { ScrollIntoView } from 'app/components/ui/scroll';
import PrivateRoute from 'app/containers/PrivateRoute'; import PrivateRoute from 'app/containers/PrivateRoute';
import AuthFlowRoute from 'app/containers/AuthFlowRoute'; import AuthFlowRoute from 'app/containers/AuthFlowRoute';
import Userbar from 'app/components/userbar/Userbar';
import { PopupStack } from 'app/components/ui/popup'; import { PopupStack } from 'app/components/ui/popup';
import * as loader from 'app/services/loader'; import * as loader from 'app/services/loader';
import { getActiveAccount } from 'app/components/accounts/reducer'; import { getActiveAccount } from 'app/components/accounts/reducer';
import { User } from 'app/components/user'; import { User } from 'app/components/user';
import { Account } from 'app/components/accounts/reducer'; import { Account } from 'app/components/accounts/reducer';
import { ComponentLoader } from 'app/components/ui/loader'; import { ComponentLoader } from 'app/components/ui/loader';
import Toolbar from './Toolbar';
import styles from './root.scss'; import styles from './root.scss';
import siteName from './siteName.intl';
import PageNotFound from 'app/pages/404/PageNotFound'; import PageNotFound from 'app/pages/404/PageNotFound';
const ProfileController = React.lazy(() => const ProfileController = React.lazy(
import(/* webpackChunkName: "page-profile-all" */ 'app/pages/profile/ProfileController'), () => import(/* webpackChunkName: "page-profile-all" */ 'app/pages/profile/ProfileController'),
); );
const RulesPage = React.lazy(() => import(/* webpackChunkName: "page-rules" */ 'app/pages/rules/RulesPage')); 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')); const DevPage = React.lazy(() => import(/* webpackChunkName: "page-dev-applications" */ 'app/pages/dev/DevPage'));
@@ -35,9 +32,6 @@ class RootPage extends React.PureComponent<{
user: User; user: User;
isPopupActive: boolean; isPopupActive: boolean;
onLogoClick: (event: React.MouseEvent<HTMLAnchorElement>) => void; onLogoClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
location: {
pathname: string;
};
}> { }> {
componentDidMount() { componentDidMount() {
this.onPageUpdate(); this.onPageUpdate();
@@ -52,9 +46,7 @@ class RootPage extends React.PureComponent<{
} }
render() { render() {
const { props } = this;
const { user, account, isPopupActive, onLogoClick } = this.props; const { user, account, isPopupActive, onLogoClick } = this.props;
const isRegisterPage = props.location.pathname === '/register';
if (document && document.body) { if (document && document.body) {
document.body.style.overflow = isPopupActive ? 'hidden' : ''; document.body.style.overflow = isPopupActive ? 'hidden' : '';
@@ -74,16 +66,7 @@ class RootPage extends React.PureComponent<{
[styles.isPopupActive]: isPopupActive, [styles.isPopupActive]: isPopupActive,
})} })}
> >
<div className={styles.header} data-testid="toolbar"> <Toolbar account={account} onLogoClick={onLogoClick} />
<div className={styles.headerContent}>
<Link to="/" className={styles.logo} onClick={onLogoClick} data-testid="home-page">
<Message {...siteName} />
</Link>
<div className={styles.userbar}>
<Userbar account={account} guestAction={isRegisterPage ? 'login' : 'register'} />
</div>
</div>
</div>
<div className={styles.body}> <div className={styles.body}>
<React.Suspense fallback={<ComponentLoader />}> <React.Suspense fallback={<ComponentLoader />}>
<Switch> <Switch>
@@ -111,15 +94,13 @@ class RootPage extends React.PureComponent<{
} }
} }
export default withRouter( export default connect(
connect( (state) => ({
(state) => ({ user: state.user,
user: state.user, account: getActiveAccount(state),
account: getActiveAccount(state), isPopupActive: state.popup.popups.length > 0,
isPopupActive: state.popup.popups.length > 0, }),
}), {
{ onLogoClick: resetAuth,
onLogoClick: resetAuth, },
}, )(RootPage);
)(RootPage),
);

View 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;

View File

@@ -1,7 +1,7 @@
@import '~app/components/ui/colors.scss'; @import '~app/components/ui/colors.scss';
@import '~app/components/ui/fonts.scss'; @import '~app/components/ui/fonts.scss';
$userBarHeight: 50px; $toolbarHeight: 50px;
.root { .root {
height: 100%; height: 100%;
@@ -9,11 +9,11 @@ $userBarHeight: 50px;
.viewPort { .viewPort {
height: 100%; height: 100%;
transition: filter 0.4s 0.1s ease;
} }
.isPopupActive { .isPopupActive {
filter: blur(5px); filter: blur(5px);
transition: filter 0.4s 0.1s ease;
} }
.wrapper { .wrapper {
@@ -21,21 +21,21 @@ $userBarHeight: 50px;
margin: 0 auto; margin: 0 auto;
} }
.header { .toolbar {
position: fixed; position: fixed;
top: 0; top: 0;
z-index: 100; z-index: 100;
height: $userBarHeight; height: $toolbarHeight;
width: 100%; width: 100%;
background: $green; background: $green;
} }
.headerContent { .toolbarContent {
composes: wrapper; composes: wrapper;
position: relative; position: relative;
} }
.logo { .siteName {
line-height: 50px; line-height: 50px;
padding: 0 20px; padding: 0 20px;
display: inline-block; display: inline-block;
@@ -44,7 +44,15 @@ $userBarHeight: 50px;
font-family: $font-family-title; font-family: $font-family-title;
font-size: 33px; font-size: 33px;
color: #fff !important; color: #fff!important; // TODO: why?
}
.userBar {
position: absolute;
right: 0;
left: 115px;
top: 0;
text-align: right;
} }
.body { .body {
@@ -55,12 +63,5 @@ $userBarHeight: 50px;
min-height: 100%; min-height: 100%;
box-sizing: border-box; box-sizing: border-box;
padding-top: $userBarHeight; // place for header padding-top: $toolbarHeight; // space for the toolbar
}
.userbar {
position: absolute;
right: 0;
left: 115px;
top: 0;
} }

View File

@@ -7,6 +7,7 @@ export interface UserResponse {
id: number; id: number;
isActive: boolean; isActive: boolean;
isOtpEnabled: boolean; isOtpEnabled: boolean;
isDeleted: boolean;
lang: string; lang: string;
passwordChangedAt: number; // timestamp passwordChangedAt: number; // timestamp
registeredAt: number; // timestamp registeredAt: number; // timestamp
@@ -16,13 +17,7 @@ export interface UserResponse {
} }
export function getInfo(id: number, token?: string): Promise<UserResponse> { export function getInfo(id: number, token?: string): Promise<UserResponse> {
return request.get( return request.get(`/api/v1/accounts/${id}`, {}, { token });
`/api/v1/accounts/${id}`,
{},
{
token,
},
);
} }
export function changePassword( export function changePassword(
@@ -86,3 +81,13 @@ export function confirmNewEmail(id: number, key: string): Promise<{ success: boo
key, 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`);
}

View File

@@ -47,6 +47,20 @@ describe('AcceptRulesState', () => {
state.enter(context); 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', () => { describe('#resolve', () => {
@@ -83,7 +97,13 @@ describe('AcceptRulesState', () => {
it('should logout', () => { it('should logout', () => {
expectRun(mock, '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 });
}); });
}); });
}); });

View File

@@ -8,7 +8,7 @@ export default class AcceptRulesState extends AbstractState {
enter(context: AuthContext): Promise<void> | void { enter(context: AuthContext): Promise<void> | void {
const { user } = context.getState(); const { user } = context.getState();
if (user.shouldAcceptRules) { if (!user.isDeleted && user.shouldAcceptRules) {
context.navigate('/accept-rules'); context.navigate('/accept-rules');
} else { } else {
context.setState(new CompleteState()); context.setState(new CompleteState());
@@ -22,7 +22,13 @@ export default class AcceptRulesState extends AbstractState {
.catch((err = {}) => err.errors || logger.warn('Error accepting rules', err)); .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'); context.run('logout');
} }
} }

View File

@@ -1,7 +1,9 @@
import expect from 'app/test/unexpected';
import sinon, { SinonMock } from 'sinon';
import ChooseAccountState from 'app/services/authFlow/ChooseAccountState'; import ChooseAccountState from 'app/services/authFlow/ChooseAccountState';
import CompleteState from 'app/services/authFlow/CompleteState'; import CompleteState from 'app/services/authFlow/CompleteState';
import LoginState from 'app/services/authFlow/LoginState'; import LoginState from 'app/services/authFlow/LoginState';
import { SinonMock } from 'sinon';
import { bootstrap, expectState, expectNavigate, expectRun, MockedAuthContext } from './helpers'; import { bootstrap, expectState, expectNavigate, expectRun, MockedAuthContext } from './helpers';
@@ -49,10 +51,18 @@ describe('ChooseAccountState', () => {
}); });
describe('#resolve', () => { describe('#resolve', () => {
it('should transition to complete if existed account was choosen', () => { it('should transition to complete if an existing account was chosen', () => {
expectRun(
mock,
'authenticate',
sinon.match({
id: 123,
}),
).returns(Promise.resolve());
expectRun(mock, 'setAccountSwitcher', false);
expectState(mock, CompleteState); expectState(mock, CompleteState);
state.resolve(context, { id: 123 }); return expect(state.resolve(context, { id: 123 }), 'to be fulfilled');
}); });
it('should transition to login if user wants to add new account', () => { it('should transition to login if user wants to add new account', () => {
@@ -60,7 +70,8 @@ describe('ChooseAccountState', () => {
expectRun(mock, 'setLogin', null); expectRun(mock, 'setLogin', null);
expectState(mock, LoginState); expectState(mock, LoginState);
state.resolve(context, {}); // Assert nothing returned
return expect(state.resolve(context, {}), 'to be undefined');
}); });
}); });

View File

@@ -1,3 +1,5 @@
import type { Account } from 'app/components/accounts/reducer';
import AbstractState from './AbstractState'; import AbstractState from './AbstractState';
import { AuthContext } from './AuthFlow'; import { AuthContext } from './AuthFlow';
import LoginState from './LoginState'; 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) { if (payload.id) {
context.setState(new CompleteState()); // payload is Account
} else { return context
context.navigate('/login'); .run('authenticate', payload)
context.run('setLogin', null); .then(() => context.run('setAccountSwitcher', false))
context.setState(new LoginState()); .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 { reject(context: AuthContext): void {

View File

@@ -22,8 +22,7 @@ describe('CompleteState', () => {
state = new CompleteState(); state = new CompleteState();
const data = bootstrap(); const data = bootstrap();
context = data.context; ({ context, mock } = data);
mock = data.mock;
}); });
afterEach(() => { afterEach(() => {
@@ -71,6 +70,22 @@ describe('CompleteState', () => {
state.enter(context); 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', () => { it('should transition to accept-rules if shouldAcceptRules', () => {
context.getState.returns({ context.getState.returns({
user: { user: {
@@ -100,157 +115,188 @@ describe('CompleteState', () => {
state.enter(context); state.enter(context);
}); });
it('should transition to finish state if code is present', () => { describe('oauth', () => {
context.getState.returns({ it('should transition to finish state if code is present', () => {
user: { context.getState.returns({
isActive: true, user: {
isGuest: false, isActive: true,
}, isGuest: false,
auth: {
oauth: {
clientId: 'ely.by',
code: 'XXX',
}, },
}, 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', () => { state.enter(context);
context.getState.returns({ });
user: {
isActive: true, it('should transition to permissions state if prompt=consent', () => {
isGuest: false, context.getState.returns({
}, user: {
auth: { isActive: true,
oauth: { isGuest: false,
clientId: 'ely.by', },
acceptRequired: true, 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', () => { state.enter(context);
context.getState.returns({ });
user: {
isActive: true, it('should transition to ChooseAccountState if user isDeleted', () => {
isGuest: false, context.getState.returns({
}, user: {
auth: { isActive: true,
oauth: { isDeleted: true,
clientId: 'ely.by', isGuest: false,
prompt: ['consent'], },
}, 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', username: 'thatUsername',
token: '', token: '',
refreshToken: '', refreshToken: '',
isDeleted: false,
}; };
context.getState.returns({ context.getState.returns({

View File

@@ -34,7 +34,7 @@ export default class CompleteState extends AbstractState {
context.setState(new LoginState()); context.setState(new LoginState());
} else if (!user.isActive) { } else if (!user.isActive) {
context.setState(new ActivationState()); context.setState(new ActivationState());
} else if (user.shouldAcceptRules) { } else if (user.shouldAcceptRules && !user.isDeleted) {
context.setState(new AcceptRulesState()); context.setState(new AcceptRulesState());
} else if (oauth && oauth.clientId) { } else if (oauth && oauth.clientId) {
return this.processOAuth(context); return this.processOAuth(context);
@@ -44,7 +44,7 @@ export default class CompleteState extends AbstractState {
} }
processOAuth(context: AuthContext): Promise<void> | void { processOAuth(context: AuthContext): Promise<void> | void {
const { auth, accounts } = context.getState(); const { auth, accounts, user } = context.getState();
let { isSwitcherEnabled } = auth; let { isSwitcherEnabled } = auth;
const { oauth } = 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()); 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) { } else if (oauth.code) {
context.setState(new FinishState()); context.setState(new FinishState());
} else { } else {

View File

@@ -74,6 +74,7 @@ describe('MfaState', () => {
}, },
}); });
expectRun(mock, 'setAccountSwitcher', false);
expectRun( expectRun(
mock, mock,
'login', 'login',

View File

@@ -19,15 +19,16 @@ export default class MfaState extends AbstractState {
} }
resolve(context: AuthContext, { totp }: { totp: string }): Promise<void> | void { resolve(context: AuthContext, { totp }: { totp: string }): Promise<void> | void {
const { login, password, rememberMe } = getCredentials(context.getState()); const { login, password, rememberMe, isRelogin } = getCredentials(context.getState());
return context return context
.run('login', { .run('login', {
totp,
password,
rememberMe,
login, login,
password,
totp,
rememberMe,
}) })
.then(() => !isRelogin && context.run('setAccountSwitcher', false))
.then(() => context.setState(new CompleteState())) .then(() => context.setState(new CompleteState()))
.catch((err = {}) => err.errors || logger.warn('Error logging in', err)); .catch((err = {}) => err.errors || logger.warn('Error logging in', err));
} }

View File

@@ -69,6 +69,7 @@ describe('PasswordState', () => {
}, },
}); });
expectRun(mock, 'setAccountSwitcher', false);
expectRun( expectRun(
mock, mock,
'login', 'login',
@@ -102,6 +103,8 @@ describe('PasswordState', () => {
}, },
}); });
// Should not run "setAccountSwitcher"
expectRun( expectRun(
mock, mock,
'login', 'login',
@@ -136,6 +139,7 @@ describe('PasswordState', () => {
}, },
}); });
expectRun(mock, 'setAccountSwitcher', false);
expectRun( expectRun(
mock, mock,
'login', 'login',
@@ -194,6 +198,7 @@ describe('PasswordState', () => {
}, },
}); });
// Should not run "setAccountSwitcher"
expectRun(mock, 'activateAccount', { id: 2 }); expectRun(mock, 'activateAccount', { id: 2 });
expectRun(mock, 'removeAccount', { id: 1 }); expectRun(mock, 'removeAccount', { id: 1 });
expectState(mock, ChooseAccountState); expectState(mock, ChooseAccountState);

View File

@@ -33,7 +33,7 @@ export default class PasswordState extends AbstractState {
rememberMe: boolean; rememberMe: boolean;
}, },
): Promise<void> | void { ): Promise<void> | void {
const { login, returnUrl } = getCredentials(context.getState()); const { login, returnUrl, isRelogin } = getCredentials(context.getState());
return context return context
.run('login', { .run('login', {
@@ -48,6 +48,10 @@ export default class PasswordState extends AbstractState {
return context.setState(new MfaState()); return context.setState(new MfaState());
} }
if (!isRelogin) {
context.run('setAccountSwitcher', false);
}
if (returnUrl) { if (returnUrl) {
context.navigate(returnUrl); context.navigate(returnUrl);

View File

@@ -11,8 +11,8 @@ import ContextProvider from './ContextProvider';
import type { History } from 'history'; import type { History } from 'history';
const SuccessOauthPage = React.lazy(() => const SuccessOauthPage = React.lazy(
import(/* webpackChunkName: "page-oauth-success" */ 'app/pages/auth/SuccessOauthPage'), () => import(/* webpackChunkName: "page-oauth-success" */ 'app/pages/auth/SuccessOauthPage'),
); );
interface Props { interface Props {

View File

@@ -28,7 +28,12 @@ export default function storeFactory(preloadedState = {}): Store {
// Hot reload reducers // Hot reload reducers
if (module.hot && typeof module.hot.accept === 'function') { 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; return store;

View File

@@ -2,7 +2,8 @@
/* eslint-env node */ /* eslint-env node */
/* eslint-disable no-console */ /* eslint-disable no-console */
import fs, { Stats } from 'fs'; import type { Stats } from 'fs';
import fs from 'fs';
import webpack, { MultiCompiler } from 'webpack'; import webpack, { MultiCompiler } from 'webpack';
import chalk from 'chalk'; import chalk from 'chalk';
@@ -37,7 +38,11 @@ Promise.all([stat(`${__dirname}/../../yarn.lock`), stat(`${__dirname}/../../dll/
return reject(err); 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(); resolve();
}); });

View File

@@ -1,4 +1,5 @@
import { account1 } from '../../fixtures/accounts.json'; import { account1 } from '../../fixtures/accounts.json';
import { UserResponse } from 'app/services/api/accounts';
const defaults = { const defaults = {
client_id: 'ely', client_id: 'ely',
@@ -39,108 +40,186 @@ describe('OAuth', () => {
cy.url().should('equal', 'https://dev.ely.by/'); cy.url().should('equal', 'https://dev.ely.by/');
}); });
it('should ask to choose an account if user has multiple', () => { describe('AccountSwitcher', () => {
cy.login({ accounts: ['default', 'default2'] }).then(({ accounts: [account] }) => { 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.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/'); cy.url().should('equal', 'https://dev.ely.by/');
}); });
}); });
// TODO: remove api mocks, when we will be able to revoke permissions describe('Deleted account', () => {
it('should prompt for permissions', () => { it('should show account switcher and then abort oauth and redirect to profile', () => {
cy.server(); 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({ cy.visit(`/oauth2/v1/ely?${new URLSearchParams(defaults)}`);
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.findByTestId('auth-header').should('contain', 'Choose an account');
cy.visit( cy.findByTestId('auth-body').contains(account.email).click();
`/oauth2/v1/ely?${new URLSearchParams({
...defaults,
client_id: 'tlauncher',
redirect_uri: 'http://localhost:8080',
})}`,
);
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.server();
cy.visit(`/oauth2/v1/ely?${new URLSearchParams(defaults)}`); 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.location('pathname').should('eq', '/');
cy.findByTestId('deletedAccount').should('contain', 'Account is deleted');
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');
}); });
describe('login_hint', () => { describe('login_hint', () => {
@@ -229,7 +308,7 @@ describe('OAuth', () => {
cy.findByTestId('auth-controls').contains('another account').click(); 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}`); 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}`); cy.get('[name=login]').type(`${account1.login}{enter}`);

View File

@@ -1,4 +1,6 @@
import { account1, account2 } from '../../fixtures/accounts.json'; import { account1, account2 } from '../../fixtures/accounts.json';
import { UserResponse } from 'app/services/api/accounts';
import { confirmWithPassword } from '../profile/utils';
describe('Sign in / Log out', () => { describe('Sign in / Log out', () => {
it('should sign in', () => { it('should sign in', () => {
@@ -131,6 +133,189 @@ describe('Sign in / Log out', () => {
cy.findByTestId('toolbar').should('contain', 'Join'); 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', () => { describe('multi account', () => {
it('should allow sign in with another account', () => { it('should allow sign in with another account', () => {
cy.login({ accounts: ['default2'] }); cy.login({ accounts: ['default2'] });

View File

@@ -1,3 +1,5 @@
import { UserResponse } from 'app/services/api/accounts';
import { openSectionByName, confirmWithPassword } from './utils'; import { openSectionByName, confirmWithPassword } from './utils';
describe('Profile — Change Username', () => { describe('Profile — Change Username', () => {
@@ -18,10 +20,11 @@ describe('Profile — Change Username', () => {
elyProfileLink: 'http://ely.by/u7', elyProfileLink: 'http://ely.by/u7',
email: 'danilenkos@auroraglobal.com', email: 'danilenkos@auroraglobal.com',
isActive: true, isActive: true,
isDeleted: false,
passwordChangedAt: 1476075696, passwordChangedAt: 1476075696,
hasMojangUsernameCollision: true, hasMojangUsernameCollision: true,
shouldAcceptRules: false, shouldAcceptRules: false,
}, } as UserResponse,
}); });
cy.route({ cy.route({

View 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');
});
});
});

View File

@@ -1,3 +1,5 @@
import { UserResponse } from 'app/services/api/accounts';
import { openSectionByName, getSectionByName, confirmWithPassword } from './utils'; import { openSectionByName, getSectionByName, confirmWithPassword } from './utils';
describe('Profile — mfa', () => { describe('Profile — mfa', () => {
@@ -63,10 +65,11 @@ describe('Profile — mfa', () => {
elyProfileLink: 'http://ely.by/u7', elyProfileLink: 'http://ely.by/u7',
email: 'danilenkos@auroraglobal.com', email: 'danilenkos@auroraglobal.com',
isActive: true, isActive: true,
isDeleted: false,
passwordChangedAt: 1476075696, passwordChangedAt: 1476075696,
hasMojangUsernameCollision: true, hasMojangUsernameCollision: true,
shouldAcceptRules: false, shouldAcceptRules: false,
}, } as UserResponse,
}); });
confirmWithPassword(account.password); confirmWithPassword(account.password);
@@ -104,10 +107,11 @@ describe('Profile — mfa', () => {
elyProfileLink: 'http://ely.by/u7', elyProfileLink: 'http://ely.by/u7',
email: 'danilenkos@auroraglobal.com', email: 'danilenkos@auroraglobal.com',
isActive: true, isActive: true,
isDeleted: false,
passwordChangedAt: 1476075696, passwordChangedAt: 1476075696,
hasMojangUsernameCollision: true, hasMojangUsernameCollision: true,
shouldAcceptRules: false, shouldAcceptRules: false,
}, } as UserResponse,
}); });
cy.route({ cy.route({
method: 'DELETE', method: 'DELETE',

View File

@@ -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);
});
});
});

View File

@@ -21,6 +21,7 @@ const wp = require('@cypress/webpack-preprocessor');
// for some reason loader can not locate babel.config. So we load it manually // for some reason loader can not locate babel.config. So we load it manually
const config = require('../../../babel.config'); const config = require('../../../babel.config');
const babelEnvName = 'browser-development';
module.exports = (on) => { module.exports = (on) => {
const options = { const options = {
@@ -39,14 +40,31 @@ module.exports = (on) => {
{ {
loader: 'babel-loader', loader: 'babel-loader',
options: { options: {
envName: 'webpack', envName: babelEnvName,
cacheDirectory: true, cacheDirectory: true,
// We don't have the webpack's API object, so just provide necessary methods // We don't have the webpack's API object, so just provide necessary methods
...config({ ...config({
env() { env(value) {
return 'development'; // 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() {},
}), }),
}, },
}, },

View File

@@ -30,13 +30,14 @@ const isCI = !!process.env.CI;
const isSilent = isCI || process.argv.some((arg) => /quiet/.test(arg)); const isSilent = isCI || process.argv.some((arg) => /quiet/.test(arg));
const isCspEnabled = false; const isCspEnabled = false;
const enableDll = !isProduction && !isStorybook; 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 smp = new SpeedMeasurePlugin();
const webpackConfig = { const webpackConfig = {
mode: isProduction ? 'production' : 'development', mode: webpackEnv,
cache: true, cache: true,
@@ -169,7 +170,7 @@ const webpackConfig = {
{ {
loader: 'babel-loader', loader: 'babel-loader',
options: { options: {
envName: 'webpack', envName: `browser-${webpackEnv}`,
cacheDirectory: true, cacheDirectory: true,
}, },
}, },

3260
yarn.lock

File diff suppressed because it is too large Load Diff