Merge branch 'device_code'

This commit is contained in:
ErickSkrauch
2024-12-23 15:16:49 +01:00
47 changed files with 978 additions and 877 deletions

View File

@@ -1,8 +1,12 @@
import React from 'react';
import React, { FC } from 'react';
import { Helmet } from 'react-helmet-async';
import { FormattedMessage as Message, MessageDescriptor } from 'react-intl';
export default function AuthTitle({ title }: { title: MessageDescriptor }) {
interface Props {
title: MessageDescriptor;
}
const AuthTitle: FC<Props> = ({ title }) => {
return (
<Message {...title}>
{(msg) => (
@@ -13,4 +17,6 @@ export default function AuthTitle({ title }: { title: MessageDescriptor }) {
)}
</Message>
);
}
};
export default AuthTitle;

View File

@@ -94,7 +94,8 @@ interface OwnProps {
Title: ReactElement;
Body: ReactElement;
Footer: ReactElement;
Links: ReactNode;
Links?: ReactNode;
className?: string;
}
interface Props extends OwnProps {
@@ -255,6 +256,7 @@ class PanelTransition extends React.PureComponent<Props, State> {
onSubmit={this.onFormSubmit}
onInvalid={this.onFormInvalid}
isLoading={this.props.auth.isLoading}
className={this.props.className}
>
<Panel>
<PanelHeader>{panels.map((config) => this.getHeader(config))}</PanelHeader>
@@ -285,10 +287,7 @@ class PanelTransition extends React.PureComponent<Props, State> {
onFormSubmit = (): void => {
this.props.clearErrors();
if (this.body) {
this.body.onFormSubmit();
}
this.body?.onFormSubmit();
};
onFormInvalid = (errors: Record<string, ValidationError>): void => this.props.setErrors(errors);
@@ -377,7 +376,7 @@ class PanelTransition extends React.PureComponent<Props, State> {
if (length === 1) {
if (!this.wasAutoFocused) {
this.body.autoFocus();
this.body?.autoFocus();
}
this.wasAutoFocused = true;

View File

@@ -16,14 +16,18 @@ import {
login,
setLogin,
} from 'app/components/auth/actions';
import { OauthData, OAuthValidateResponse } from '../../services/api/oauth';
import { OAuthValidateResponse } from 'app/services/api/oauth';
const oauthData: OauthData = {
clientId: '',
redirectUrl: '',
responseType: '',
scope: '',
state: '',
import { OAuthState } from './reducer';
const oauthData: OAuthState = {
params: {
clientId: '',
redirectUrl: '',
responseType: '',
scope: '',
state: '',
},
prompt: 'none',
};
@@ -64,9 +68,6 @@ describe('components/auth/actions', () => {
name: '',
description: '',
},
oAuth: {
state: 123,
},
session: {
scopes: ['account_info'],
},
@@ -86,8 +87,14 @@ describe('components/auth/actions', () => {
[setClient(resp.client)],
[
setOAuthRequest({
...resp.oAuth,
prompt: 'none',
params: {
clientId: '',
redirectUrl: '',
responseType: '',
state: '',
scope: '',
},
prompt: ['none'],
loginHint: undefined,
}),
],
@@ -114,7 +121,7 @@ describe('components/auth/actions', () => {
return callThunk(oAuthComplete).then(() => {
expect(request.post, 'to have a call satisfying', [
'/api/oauth2/v1/complete?client_id=&redirect_uri=&response_type=&description=&scope=&prompt=none&login_hint=&state=',
'/api/oauth2/v1/complete?client_id=&redirect_uri=&response_type=&scope=&state=',
{},
]);
});

View File

@@ -13,13 +13,12 @@ import {
recoverPassword as recoverPasswordEndpoint,
OAuthResponse,
} from 'app/services/api/authentication';
import oauth, { OauthData, Scope } from 'app/services/api/oauth';
import oauth, { OauthRequestData, Scope } from 'app/services/api/oauth';
import {
register as registerEndpoint,
activate as activateEndpoint,
resendActivation as resendActivationEndpoint,
} from 'app/services/api/signup';
import dispatchBsod from 'app/components/ui/bsod/dispatchBsod';
import { create as createPopup } from 'app/components/ui/popup/actions';
import ContactForm from 'app/components/contact';
import { Account } from 'app/components/accounts/reducer';
@@ -314,38 +313,17 @@ const KNOWN_SCOPES: ReadonlyArray<string> = [
'account_info',
'account_email',
];
/**
* @param {object} oauthData
* @param {string} oauthData.clientId
* @param {string} oauthData.redirectUrl
* @param {string} oauthData.responseType
* @param {string} oauthData.description
* @param {string} oauthData.scope
* @param {string} [oauthData.prompt='none'] - comma-separated list of values to adjust auth flow
* Posible values:
* * none - default behaviour
* * consent - forcibly prompt user for rules acceptance
* * select_account - force account choosage, even if user has only one
* @param {string} oauthData.loginHint - allows to choose the account, which will be used for auth
* The possible values: account id, email, username
* @param {string} oauthData.state
*
* @returns {Promise}
*/
export function oAuthValidate(oauthData: OauthData) {
export function oAuthValidate(oauthData: Pick<OAuthState, 'params' | 'description' | 'prompt' | 'loginHint'>) {
// TODO: move to oAuth actions?
// test request: /oauth?client_id=ely&redirect_uri=http%3A%2F%2Fely.by&response_type=code&scope=minecraft_server_session&description=foo
// auth code flow: /oauth?client_id=ely&redirect_uri=http%3A%2F%2Fely.by&response_type=code&scope=minecraft_server_session&description=foo
// device code flow: /code?user_code=XOXOXOXO
return wrapInLoader((dispatch) =>
oauth
.validate(oauthData)
.validate(getOAuthRequest(oauthData))
.then((resp) => {
const { scopes } = resp.session;
const invalidScopes = scopes.filter((scope) => !KNOWN_SCOPES.includes(scope));
let prompt = (oauthData.prompt || 'none').split(',').map((item) => item.trim());
if (prompt.includes('none')) {
prompt = ['none'];
}
if (invalidScopes.length) {
logger.error('Got invalid scopes after oauth validation', {
@@ -353,12 +331,19 @@ export function oAuthValidate(oauthData: OauthData) {
});
}
let { prompt } = oauthData;
if (prompt && !Array.isArray(prompt)) {
prompt = prompt.split(',').map((item) => item.trim());
}
dispatch(setClient(resp.client));
dispatch(
setOAuthRequest({
...resp.oAuth,
prompt: oauthData.prompt || 'none',
params: oauthData.params,
description: oauthData.description,
loginHint: oauthData.loginHint,
prompt,
}),
);
dispatch(setScopes(scopes));
@@ -375,12 +360,6 @@ export function oAuthValidate(oauthData: OauthData) {
);
}
/**
* @param {object} params
* @param {bool} params.accept=false
* @param params.accept
* @returns {Promise}
*/
export function oAuthComplete(params: { accept?: boolean } = {}) {
return wrapInLoader(
async (
@@ -388,7 +367,7 @@ export function oAuthComplete(params: { accept?: boolean } = {}) {
getState,
): Promise<{
success: boolean;
redirectUri: string;
redirectUri?: string;
}> => {
const oauthData = getState().auth.oauth;
@@ -397,13 +376,21 @@ export function oAuthComplete(params: { accept?: boolean } = {}) {
}
try {
const resp = await oauth.complete(oauthData, params);
const resp = await oauth.complete(getOAuthRequest(oauthData), params);
localStorage.removeItem('oauthData');
if (resp.redirectUri.startsWith('static_page')) {
const displayCode = /static_page_with_code/.test(resp.redirectUri);
if (!resp.redirectUri) {
dispatch(
setOAuthCode({
// if accept is undefined, then it was auto approved
success: resp.success && (typeof params.accept === 'undefined' || params.accept),
}),
);
} else if (resp.redirectUri.startsWith('static_page')) {
const displayCode = resp.redirectUri.includes('static_page_with_code');
const [, code] = resp.redirectUri.match(/code=(.+)&/) || [];
const [, code] = resp.redirectUri.match(/code=([^&]+)/) || [];
[, resp.redirectUri] = resp.redirectUri.match(/^(.+)\?/) || [];
dispatch(
@@ -437,13 +424,36 @@ export function oAuthComplete(params: { accept?: boolean } = {}) {
);
}
function getOAuthRequest({ params, description }: OAuthState): OauthRequestData {
let data: OauthRequestData;
if ('userCode' in params) {
data = {
user_code: params.userCode,
};
} else {
data = {
client_id: params.clientId,
redirect_uri: params.redirectUrl,
response_type: params.responseType,
scope: params.scope,
state: params.state,
};
}
if (description) {
data.description = description;
}
return data;
}
function handleOauthParamsValidation(
resp: {
[key: string]: any;
userMessage?: string;
} = {},
) {
dispatchBsod();
localStorage.removeItem('oauthData');
// eslint-disable-next-line no-alert
@@ -468,33 +478,17 @@ export type ClientAction = SetClientAction;
interface SetOauthAction extends ReduxAction {
type: 'set_oauth';
payload: Pick<OAuthState, 'clientId' | 'redirectUrl' | 'responseType' | 'scope' | 'prompt' | 'loginHint' | 'state'>;
payload: OAuthState | null;
}
type SetOauthRequestPayload = Pick<OAuthState, 'params' | 'description' | 'loginHint' | 'prompt'>;
// Input data is coming right from the query string, so the names
// are the same, as used for initializing OAuth2 request
export function setOAuthRequest(data: {
client_id?: string;
redirect_uri?: string;
response_type?: string;
scope?: string;
prompt?: string;
loginHint?: string;
state?: string;
}): SetOauthAction {
export function setOAuthRequest(payload: SetOauthRequestPayload | null): SetOauthAction {
return {
type: 'set_oauth',
payload: {
// TODO: there is too much default empty string. Maybe we can somehow validate it
// on the level, where this action is called?
clientId: data.client_id || '',
redirectUrl: data.redirect_uri || '',
responseType: data.response_type || '',
scope: data.scope || '',
prompt: data.prompt || '',
loginHint: data.loginHint || '',
state: data.state || '',
},
payload,
};
}
@@ -503,9 +497,7 @@ interface SetOAuthResultAction extends ReduxAction {
payload: Pick<OAuthState, 'success' | 'code' | 'displayCode'>;
}
export const SET_OAUTH_RESULT = 'set_oauth_result'; // TODO: remove
export function setOAuthCode(payload: { success: boolean; code: string; displayCode: boolean }): SetOAuthResultAction {
export function setOAuthCode(payload: Pick<OAuthState, 'success' | 'code' | 'displayCode'>): SetOAuthResultAction {
return {
type: 'set_oauth_result',
payload,
@@ -515,7 +507,7 @@ export function setOAuthCode(payload: { success: boolean; code: string; displayC
export function resetOAuth(): AppAction {
return (dispatch): void => {
localStorage.removeItem('oauthData');
dispatch(setOAuthRequest({}));
dispatch(setOAuthRequest(null));
};
}

View File

@@ -31,7 +31,7 @@ const AuthError: ComponentType<Props> = ({ error, onClose }) => {
}, [error, onClose]);
return (
<PanelBodyHeader type="error" onClose={onClose}>
<PanelBodyHeader type="error" onClose={onClose} data-testid="auth-error">
{resolveError(error)}
</PanelBodyHeader>
);

View File

@@ -0,0 +1,32 @@
import React, { FC } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { FormattedMessage as Message, defineMessages } from 'react-intl';
import { Button } from 'app/components/ui/form';
import AuthTitle from 'app/components/auth/AuthTitle';
import PanelTransition from 'app/components/auth/PanelTransition';
import style from './deviceCode.scss';
import DeviceCodeBody from './DeviceCodeBody';
const messages = defineMessages({
deviceCodeTitle: 'Device Code',
});
const DeviceCode: FC<RouteComponentProps> = (props) => {
return (
<PanelTransition
key="deviceCode"
className={style.form}
Title={<AuthTitle title={messages.deviceCodeTitle} />}
Body={<DeviceCodeBody {...props} />}
Footer={
<Button type="submit">
<Message id="continue" defaultMessage="Cotinute" />
</Button>
}
/>
);
};
export default DeviceCode;

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { FormattedMessage as Message } from 'react-intl';
import { Input } from 'app/components/ui/form';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
import style from './deviceCode.scss';
export default class DeviceCodeBody extends BaseAuthBody {
static displayName = 'DeviceCodeBody';
static panelId = 'deviceCode';
autoFocusField = 'user_code';
render() {
return (
<>
{this.renderErrors()}
<div className={style.icon} />
<div className={style.description}>
<Message
id="deviceCodeDescription"
defaultMessage="To authorize the application, enter the code displayed on the screen."
/>
</div>
<Message id="enterDeviceCode" defaultMessage="Enter Device Code">
{(nodes) => (
<Input
{...this.bindField('user_code')}
autoFocus
center
required
placeholder={nodes as string}
/>
)}
</Message>
</>
);
}
}

View File

@@ -0,0 +1,22 @@
.form {
max-width: 340px;
margin: 0 auto;
padding: 55px 13px 0;
}
.description {
font-size: 15px;
color: #aaa;
text-align: center;
margin-bottom: 15px;
}
.icon {
composes: hash from '~app/components/ui/icons.scss';
display: block;
text-align: center;
font-size: 69px;
color: #ccc;
margin-block: 5px 15px;
}

View File

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

View File

@@ -1,8 +1,9 @@
import React, { MouseEventHandler } from 'react';
import React, { FC, MouseEventHandler, useEffect } from 'react';
import { Redirect } from 'react-router-dom';
import { FormattedMessage as Message } from 'react-intl';
import { Helmet } from 'react-helmet-async';
import { connect } from 'app/functions';
import { useReduxSelector } from 'app/functions';
import { Button } from 'app/components/ui/form';
import copy from 'app/services/copy';
@@ -16,102 +17,93 @@ interface Props {
success?: boolean;
}
class Finish extends React.Component<Props> {
render() {
const { appName, code, state, displayCode, success } = this.props;
const authData = JSON.stringify({
auth_code: code,
state,
const Finish: FC<Props> = () => {
const { client, oauth } = useReduxSelector((state) => state.auth);
const onCopyClick: MouseEventHandler = (event) => {
event.preventDefault();
copy(oauth!.code!);
};
let authData: string | undefined;
if (oauth && 'state' in oauth.params) {
authData = JSON.stringify({
auth_code: oauth.code,
state: oauth.params.state,
});
}
history.pushState(null, document.title, `#${authData}`);
useEffect(() => {
if (authData) {
history.pushState(null, document.title, `#${authData}`);
}
}, []);
return (
<div className={styles.finishPage}>
<Helmet title={authData} />
if (!client || !oauth) {
return <Redirect to="/" />;
}
{success ? (
<div>
<div className={styles.successBackground} />
<div className={styles.greenTitle}>
<Message
key="authForAppSuccessful"
defaultMessage="Authorization for {appName} was successfully completed"
values={{
appName: <span className={styles.appName}>{appName}</span>,
}}
/>
</div>
{displayCode ? (
<div data-testid="oauth-code-container">
<div className={styles.description}>
<Message
key="passCodeToApp"
defaultMessage="To complete authorization process, please, provide the following code to {appName}"
values={{ appName }}
/>
</div>
<div className={styles.codeContainer}>
<div className={styles.code}>{code}</div>
</div>
<Button color="green" small onClick={this.onCopyClick}>
<Message key="copy" defaultMessage="Copy" />
</Button>
</div>
) : (
return (
<div className={styles.finishPage}>
{authData && <Helmet title={authData} />}
{oauth.success ? (
<div>
<div className={styles.successBackground} />
<div className={styles.greenTitle}>
<Message
key="authForAppSuccessful"
defaultMessage="Authorization for {appName} was successfully completed"
values={{
appName: <span className={styles.appName}>{client.name}</span>,
}}
/>
</div>
{oauth.displayCode ? (
<div data-testid="oauth-code-container">
<div className={styles.description}>
<Message
key="waitAppReaction"
defaultMessage="Please, wait till your application response"
key="passCodeToApp"
defaultMessage="To complete authorization process, please, provide the following code to {appName}"
values={{ appName: client.name }}
/>
</div>
)}
</div>
) : (
<div>
<div className={styles.failBackground} />
<div className={styles.redTitle}>
<Message
key="authForAppFailed"
defaultMessage="Authorization for {appName} was failed"
values={{
appName: <span className={styles.appName}>{appName}</span>,
}}
/>
<div className={styles.codeContainer}>
<div className={styles.code}>{oauth.code}</div>
</div>
<Button color="green" small onClick={onCopyClick}>
<Message key="copy" defaultMessage="Copy" />
</Button>
</div>
) : (
<div className={styles.description}>
<Message
key="waitAppReaction"
defaultMessage="Please, wait till your application response"
/>
</div>
)}
</div>
) : (
<div>
<div className={styles.failBackground} />
<div className={styles.redTitle}>
<Message
key="authForAppFailed"
defaultMessage="Authorization for {appName} was failed"
values={{
appName: <span className={styles.appName}>{client.name}</span>,
}}
/>
</div>
)}
</div>
);
}
<div className={styles.description}>
<Message key="waitAppReaction" defaultMessage="Please, wait till your application response" />
</div>
</div>
)}
</div>
);
};
onCopyClick: MouseEventHandler = (event) => {
event.preventDefault();
const { code } = this.props;
if (code) {
copy(code);
}
};
}
export default connect(({ auth }) => {
if (!auth || !auth.client || !auth.oauth) {
throw new Error('Can not connect Finish component. No auth data in state');
}
return {
appName: auth.client.name,
code: auth.oauth.code,
displayCode: auth.oauth.displayCode,
state: auth.oauth.state,
success: auth.oauth.success,
};
})(Finish);
export default Finish;

View File

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

View File

@@ -38,15 +38,34 @@ export interface Client {
description: string;
}
export interface OAuthState {
export interface OauthAuthCodeFlowParams {
clientId: string;
redirectUrl: string;
responseType: string;
description?: string;
scope: string;
prompt: string;
loginHint: string;
state: string;
scope: string;
}
export interface OauthDeviceCodeFlowParams {
userCode: string;
}
export interface OAuthState {
params: OauthAuthCodeFlowParams | OauthDeviceCodeFlowParams;
description?: string;
/**
* Possible values:
* - none - default behaviour
* - consent - forcibly prompt user for rules acceptance
* - select_account - force account choosage, even if user has only one
* comma separated list of 'none' | 'consent' | 'select_account';
*/
prompt?: string | Array<string>;
/**
* Allows to choose the account, which will be used for auth
* The possible values: account id, email, username
*/
loginHint?: string;
success?: boolean;
code?: string;
displayCode?: boolean;

View File

@@ -1,121 +1,92 @@
import React from 'react';
import React, { FC, PropsWithChildren, useState, useCallback } from 'react';
import clsx from 'clsx';
import { omit } from 'app/functions';
import styles from './panel.scss';
import icons from './icons.scss';
export function Panel(props: { title?: string; icon?: string; children: React.ReactNode }) {
const { title: titleText, icon: iconType } = props;
let icon: React.ReactElement | undefined;
let title: React.ReactElement | undefined;
if (iconType) {
icon = (
<button className={styles.headerControl}>
<span className={icons[iconType]} />
</button>
);
}
if (titleText) {
title = (
<PanelHeader>
{icon}
{titleText}
</PanelHeader>
);
}
interface PanelProps extends PropsWithChildren<any> {
title?: string;
icon?: string;
}
export const Panel: FC<PanelProps> = ({ title, icon, children }) => {
return (
<div className={styles.panel}>
{title}
{title && (
<PanelHeader>
{icon && (
<button className={styles.headerControl}>
<span className={icons[icon]} />
</button>
)}
{title}
</PanelHeader>
)}
{props.children}
{children}
</div>
);
}
};
export function PanelHeader(props: { children: React.ReactNode }) {
export const PanelHeader: FC<PropsWithChildren<any>> = ({ children }) => {
return (
<div className={styles.header} {...props} data-testid="auth-header">
{props.children}
<div className={styles.header} data-testid="auth-header">
{children}
</div>
);
}
};
export function PanelBody(props: { children: React.ReactNode }) {
export const PanelBody: FC<PropsWithChildren<any>> = ({ children }) => {
return (
<div className={styles.body} {...props} data-testid="auth-body">
{props.children}
<div className={styles.body} data-testid="auth-body">
{children}
</div>
);
}
};
export function PanelFooter(props: { children: React.ReactNode }) {
export const PanelFooter: FC<PropsWithChildren<any>> = ({ children }) => {
return (
<div className={styles.footer} {...props} data-testid="auth-controls">
{props.children}
<div className={styles.footer} data-testid="auth-controls">
{children}
</div>
);
};
interface PanelBodyHeaderProps extends PropsWithChildren<any> {
type?: 'default' | 'error';
onClose?: () => void;
}
export class PanelBodyHeader extends React.Component<
{
type?: 'default' | 'error';
onClose?: () => void;
children: React.ReactNode;
},
{
isClosed: boolean;
}
> {
state: {
isClosed: boolean;
} = {
isClosed: false,
};
export const PanelBodyHeader: FC<PanelBodyHeaderProps> = ({ type = 'default', onClose, children, ...props }) => {
const [isClosed, setIsClosed] = useState<boolean>(false);
const handleCloseClick = useCallback(() => {
setIsClosed(true);
onClose?.();
}, [onClose]);
render() {
const { type = 'default', children } = this.props;
return (
<div
className={clsx({
[styles.defaultBodyHeader]: type === 'default',
[styles.errorBodyHeader]: type === 'error',
[styles.isClosed]: isClosed,
})}
{...props}
>
{type === 'error' && <span className={styles.close} onClick={handleCloseClick} />}
{children}
</div>
);
};
let close;
if (type === 'error') {
close = <span className={styles.close} onClick={this.onClose} />;
}
const className = clsx(styles[`${type}BodyHeader`], {
[styles.isClosed]: this.state.isClosed,
});
const extraProps = omit(this.props, ['type', 'onClose']);
return (
<div className={className} {...extraProps}>
{close}
{children}
</div>
);
}
onClose = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
const { onClose } = this.props;
this.setState({ isClosed: true });
if (onClose) {
onClose();
}
};
interface PanelIconProps {
icon: string;
}
export function PanelIcon({ icon }: { icon: string }) {
export const PanelIcon: FC<PanelIconProps> = ({ icon }) => {
return (
<div className={styles.panelIcon}>
<span className={icons[icon]} />
</div>
);
}
};

View File

@@ -1,4 +1,4 @@
import React from 'react';
import React, { ReactNode } from 'react';
import clsx from 'clsx';
import logger from 'app/services/logger';
@@ -10,7 +10,8 @@ interface BaseProps {
id: string;
isLoading: boolean;
onInvalid: (errors: Record<string, string>) => void;
children: React.ReactNode;
className?: string;
children?: ReactNode;
}
interface PropsWithoutForm extends BaseProps {
@@ -24,10 +25,6 @@ interface PropsWithForm extends BaseProps {
type Props = PropsWithoutForm | PropsWithForm;
function hasForm(props: Props): props is PropsWithForm {
return 'form' in props;
}
interface State {
id: string; // just to track value for derived updates
isTouched: boolean;
@@ -54,10 +51,7 @@ export default class Form extends React.Component<Props, State> {
mounted = false;
componentDidMount() {
if (hasForm(this.props)) {
this.props.form.addLoadingListener(this.onLoading);
}
(this.props as PropsWithForm).form?.addLoadingListener(this.onLoading);
this.mounted = true;
}
@@ -77,8 +71,8 @@ export default class Form extends React.Component<Props, State> {
}
componentDidUpdate(prevProps: Props) {
const nextForm = hasForm(this.props) ? this.props.form : undefined;
const prevForm = hasForm(prevProps) ? prevProps.form : undefined;
const nextForm = (this.props as PropsWithForm).form;
const prevForm = (prevProps as PropsWithForm).form;
if (nextForm !== prevForm) {
if (prevForm) {
@@ -92,10 +86,7 @@ export default class Form extends React.Component<Props, State> {
}
componentWillUnmount() {
if (hasForm(this.props)) {
this.props.form.removeLoadingListener(this.onLoading);
}
(this.props as PropsWithForm).form?.removeLoadingListener(this.onLoading);
this.mounted = false;
}
@@ -104,7 +95,7 @@ export default class Form extends React.Component<Props, State> {
return (
<form
className={clsx(styles.form, {
className={clsx(styles.form, this.props.className, {
[styles.isFormLoading]: isLoading,
[styles.formTouched]: this.state.isTouched,
})}
@@ -134,12 +125,10 @@ export default class Form extends React.Component<Props, State> {
this.clearErrors();
let result: Promise<void> | void;
if (hasForm(this.props)) {
// @ts-ignore this prop has default value
result = this.props.onSubmit(this.props.form);
if ((this.props as PropsWithForm).form) {
result = (this.props as PropsWithForm).onSubmit!((this.props as PropsWithForm).form);
} else {
// @ts-ignore this prop has default value
result = this.props.onSubmit(new FormData(form));
result = (this.props as PropsWithoutForm).onSubmit!(new FormData(form));
}
if (result && result.then) {
@@ -181,14 +170,11 @@ export default class Form extends React.Component<Props, State> {
}
setErrors(errors: { [key: string]: string }) {
if (hasForm(this.props)) {
this.props.form.setErrors(errors);
}
(this.props as PropsWithForm).form?.setErrors(errors);
this.props.onInvalid(errors);
}
clearErrors = () => hasForm(this.props) && this.props.form.clearErrors();
clearErrors = () => (this.props as PropsWithForm).form?.clearErrors();
onFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();

View File

@@ -76,6 +76,7 @@ $bodyTopBottomPadding: 15px;
padding: 10px 20px;
margin: (-$bodyTopBottomPadding) (-$bodyLeftRightPadding) 15px;
max-height: 200px;
text-align: center;
transition: 0.4s ease;
}

View File

@@ -1,19 +1,17 @@
import React from 'react';
import { Route, RouteProps } from 'react-router-dom';
import React, { FC, ComponentType } from 'react';
import { Route, RouteProps, RouteComponentProps } from 'react-router-dom';
import AuthFlowRouteContents from './AuthFlowRouteContents';
export default function AuthFlowRoute(props: RouteProps) {
const { component: Component, ...routeProps } = props;
if (!Component) {
throw new Error('props.component required');
}
// Make "component" prop required
type Props = Omit<RouteProps, 'component'> & {
component: ComponentType<RouteComponentProps>;
};
const AuthFlowRoute: FC<Props> = ({ component: Component, ...props }) => {
return (
<Route
{...routeProps}
render={(routerProps) => <AuthFlowRouteContents routerProps={routerProps} component={Component} />}
/>
<Route {...props} render={(routerProps) => <AuthFlowRouteContents component={Component} {...routerProps} />} />
);
}
};
export default AuthFlowRoute;

View File

@@ -1,62 +0,0 @@
import React from 'react';
import sinon from 'sinon';
import uxpect from 'app/test/unexpected';
import { render, screen } from '@testing-library/react';
import authFlow from 'app/services/authFlow';
import AuthFlowRouteContents from './AuthFlowRouteContents';
describe('AuthFlowRouteContents', () => {
beforeEach(() => {
sinon.stub(authFlow, 'handleRequest');
});
afterEach(() => {
(authFlow.handleRequest as any).restore();
});
let componentProps: { [key: string]: any };
function Component(props: { [key: string]: any }) {
componentProps = props;
return <div data-testid="test-component" />;
}
it('should render component if route allowed', () => {
const authRequest = {
path: '/path',
params: { foo: 1 },
query: new URLSearchParams(),
};
const routerProps: any = {
location: {
pathname: authRequest.path,
search: '',
query: new URLSearchParams(),
},
match: {
params: authRequest.params,
},
};
(authFlow.handleRequest as any).callsArg(2);
render(<AuthFlowRouteContents routerProps={routerProps} component={Component} />);
const component = screen.getByTestId('test-component');
uxpect(authFlow.handleRequest, 'to have a call satisfying', [
{
...authRequest,
query: uxpect.it('to be a', URLSearchParams),
},
uxpect.it('to be a function'),
uxpect.it('to be a function'),
]);
expect(component).toBeInTheDocument();
uxpect(componentProps, 'to equal', routerProps);
});
});

View File

@@ -1,89 +1,42 @@
import React from 'react';
import { Redirect, RouteComponentProps } from 'react-router-dom';
import React, { FC, ReactElement, ComponentType, useEffect, useState } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { useIsMounted } from 'app/hooks';
import authFlow from 'app/services/authFlow';
interface Props {
component: React.ComponentType<RouteComponentProps<any>> | React.ComponentType<any>;
routerProps: RouteComponentProps;
interface Props extends RouteComponentProps {
component: ComponentType<RouteComponentProps>;
}
interface State {
access: null | 'rejected' | 'allowed';
component: React.ReactElement | null;
}
const AuthFlowRouteContents: FC<Props> = ({ component: WantedComponent, location, match, history }) => {
const isMounted = useIsMounted();
const [component, setComponent] = useState<ReactElement | null>(null);
export default class AuthFlowRouteContents extends React.Component<Props, State> {
state: State = {
access: null,
component: null,
};
mounted = false;
shouldComponentUpdate({ routerProps: nextRoute, component: nextComponent }: Props, state: State) {
const { component: prevComponent, routerProps: prevRoute } = this.props;
return (
prevRoute.location.pathname !== nextRoute.location.pathname ||
prevRoute.location.search !== nextRoute.location.search ||
prevComponent !== nextComponent ||
this.state.access !== state.access
);
}
componentDidMount() {
this.mounted = true;
this.handleProps(this.props);
}
componentDidUpdate() {
this.handleProps(this.props);
}
componentWillUnmount() {
this.mounted = false;
}
render() {
return this.state.component;
}
handleProps(props: Props) {
const { routerProps } = props;
useEffect(() => {
// Promise that will be resolved after handleRequest might contain already non-actual component to render,
// so set it to false in the effect's clear function to prevent unwanted UI state
let isActual = true;
authFlow.handleRequest(
{
path: routerProps.location.pathname,
params: routerProps.match.params,
query: new URLSearchParams(routerProps.location.search),
path: location.pathname,
params: match.params,
query: new URLSearchParams(location.search),
},
history.push,
() => {
if (isActual && isMounted()) {
setComponent(<WantedComponent history={history} location={location} match={match} />);
}
},
this.onRedirect.bind(this),
this.onRouteAllowed.bind(this, props),
);
}
onRedirect(path: string) {
if (!this.mounted) {
return;
}
return () => {
isActual = false;
};
}, [location.pathname, location.search]);
this.setState({
access: 'rejected',
component: <Redirect to={path} />,
});
}
return component;
};
onRouteAllowed(props: Props) {
if (!this.mounted) {
return;
}
const { component: Component } = props;
this.setState({
access: 'allowed',
component: <Component {...props.routerProps} />,
});
}
}
export default AuthFlowRouteContents;

View File

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

View File

@@ -0,0 +1,17 @@
import { useCallback, useEffect, useRef } from 'react';
// This code is copied from the usehooks-ts: https://usehooks-ts.com/react-hook/use-is-mounted
// Replace it with the library when the Node.js version of the project will be updated at least to 16.
export default function useIsMounted(): () => boolean {
const isMounted = useRef(false);
useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);
return useCallback(() => isMounted.current, []);
}

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 78 69">
<path d="M43.18 40 46 29H34.82L32 40h11.18zm34.79-22.39-2.53 10.06a1.45 1.45 0 0 1-1.4 1.08H59.27l-2.89 11.5h14.05c.45 0 .85.23 1.12.54.28.36.41.8.28 1.26L69.3 52.1a1.4 1.4 0 0 1-1.4 1.08H53.13l-3.65 14.73a1.49 1.49 0 0 1-1.4 1.08H37.96c-.45 0-.9-.23-1.17-.54a1.55 1.55 0 0 1-.27-1.26l3.52-14.01H28.57l-3.66 14.73a1.49 1.49 0 0 1-1.4 1.08H13.35c-.4 0-.85-.23-1.12-.54a1.55 1.55 0 0 1-.27-1.26l3.52-14.01H1.43a1.51 1.51 0 0 1-1.13-.54 1.55 1.55 0 0 1-.27-1.26l2.53-10.06c.18-.63.72-1.08 1.4-1.08h14.77l2.89-11.5H7.57a1.51 1.51 0 0 1-1.13-.54c-.27-.36-.4-.8-.27-1.26L8.7 16.9a1.4 1.4 0 0 1 1.4-1.08h14.77l3.65-14.73A1.5 1.5 0 0 1 29.97 0h10.11c.41 0 .86.23 1.13.54.27.36.36.8.27 1.26L37.96 15.8h11.47l3.66-14.73A1.5 1.5 0 0 1 54.53 0h10.12c.4 0 .85.23 1.13.54.27.36.36.8.27 1.26L62.52 15.8h14.05c.45 0 .85.23 1.13.54.27.36.36.81.27 1.26z" />
</svg>

After

Width:  |  Height:  |  Size: 912 B

View File

@@ -49,6 +49,7 @@
"@types/rtl-detect": "^1.0.0",
"@types/webfontloader": "^1.6.30",
"@types/webpack-env": "^1.15.2",
"synchronous-promise": "^2.0.17",
"utility-types": "^3.10.0"
}
}

View File

@@ -1,5 +1,6 @@
import React, { ComponentType, ReactNode, useCallback, useState } from 'react';
import React, { FC, useCallback, useState } from 'react';
import { Route, Switch, Redirect, RouteComponentProps } from 'react-router-dom';
import clsx from 'clsx';
import AppInfo from 'app/components/auth/appInfo/AppInfo';
import PanelTransition from 'app/components/auth/PanelTransition';
@@ -14,7 +15,7 @@ import AcceptRules from 'app/components/auth/acceptRules/AcceptRules';
import ForgotPassword from 'app/components/auth/forgotPassword/ForgotPassword';
import RecoverPassword from 'app/components/auth/recoverPassword/RecoverPassword';
import Mfa from 'app/components/auth/mfa/Mfa';
import Finish from 'app/components/auth/finish/Finish';
import Finish from 'app/components/auth/finish';
import { useReduxSelector } from 'app/functions';
import { Factory } from 'app/components/auth/factory';
@@ -27,7 +28,7 @@ import styles from './auth.scss';
// so that it persist disregarding remounts
let isSidebarHiddenCache = false;
const AuthPage: ComponentType = () => {
const AuthPage: FC = () => {
const [isSidebarHidden, setIsSidebarHidden] = useState<boolean>(isSidebarHiddenCache);
const client = useReduxSelector((state) => state.auth.client);
@@ -37,8 +38,8 @@ const AuthPage: ComponentType = () => {
}, []);
return (
<div>
<div className={isSidebarHidden ? styles.hiddenSidebar : styles.sidebar}>
<>
<div className={clsx(styles.sidebar, { [styles.hiddenSidebar]: isSidebarHidden })}>
<AppInfo {...client} onGoToAuth={goToAuth} />
</div>
@@ -60,11 +61,11 @@ const AuthPage: ComponentType = () => {
<Redirect to="/404" />
</Switch>
</div>
</div>
</>
);
};
function renderPanelTransition(factory: Factory): (props: RouteComponentProps<any>) => ReactNode {
function renderPanelTransition(factory: Factory): FC<RouteComponentProps> {
const { Title, Body, Footer, Links } = factory();
return (props) => (

View File

@@ -14,8 +14,6 @@ $sidebar-width: 320px;
}
.hiddenSidebar {
composes: sidebar;
display: none;
}

View File

@@ -1,8 +1,8 @@
import React from 'react';
import React, { FC, useCallback, useEffect } from 'react';
import { Route, Switch } from 'react-router-dom';
import clsx from 'clsx';
import { connect } from 'app/functions';
import { useReduxSelector, useReduxDispatch } from 'app/functions';
import { resetAuth } from 'app/components/auth/actions';
import { ScrollIntoView } from 'app/components/ui/scroll';
import PrivateRoute from 'app/containers/PrivateRoute';
@@ -10,14 +10,12 @@ import AuthFlowRoute from 'app/containers/AuthFlowRoute';
import { PopupStack } from 'app/components/ui/popup';
import * as loader from 'app/services/loader';
import { getActiveAccount } from 'app/components/accounts/reducer';
import { User } from 'app/components/user';
import { Account } from 'app/components/accounts/reducer';
import { ComponentLoader } from 'app/components/ui/loader';
import Toolbar from './Toolbar';
import PageNotFound from 'app/pages/404/PageNotFound';
import DeviceCode from 'app/components/auth/deviceCode';
import styles from './root.scss';
import PageNotFound from 'app/pages/404/PageNotFound';
import Toolbar from './Toolbar';
const ProfileController = React.lazy(
() => import(/* webpackChunkName: "page-profile-all" */ 'app/pages/profile/ProfileController'),
@@ -26,76 +24,56 @@ const RulesPage = React.lazy(() => import(/* webpackChunkName: "page-rules" */ '
const DevPage = React.lazy(() => import(/* webpackChunkName: "page-dev-applications" */ 'app/pages/dev/DevPage'));
const AuthPage = React.lazy(() => import(/* webpackChunkName: "page-auth" */ 'app/pages/auth/AuthPage'));
class RootPage extends React.PureComponent<{
account: Account | null;
user: User;
isPopupActive: boolean;
onLogoClick: (event: React.MouseEvent<HTMLAnchorElement>) => void;
}> {
componentDidMount() {
this.onPageUpdate();
}
const RootPage: FC = () => {
const dispatch = useReduxDispatch();
const user = useReduxSelector((state) => state.user);
const account = useReduxSelector(getActiveAccount);
const isPopupActive = useReduxSelector((state) => state.popup.popups.length > 0);
componentDidUpdate() {
this.onPageUpdate();
}
const onLogoClick = useCallback(() => {
dispatch(resetAuth());
}, []);
onPageUpdate() {
useEffect(() => {
loader.hide();
}
}); // No deps, effect must be called on every update
render() {
const { user, account, isPopupActive, onLogoClick } = this.props;
return (
<div className={styles.root}>
<ScrollIntoView top />
if (document && document.body) {
document.body.style.overflow = isPopupActive ? 'hidden' : '';
}
<div
id="view-port"
className={clsx(styles.viewPort, {
[styles.isPopupActive]: isPopupActive,
})}
>
<Toolbar account={account} onLogoClick={onLogoClick} />
<div className={styles.body}>
<React.Suspense fallback={<ComponentLoader />}>
<Switch>
<PrivateRoute path="/profile" component={ProfileController} />
<Route path="/404" component={PageNotFound} />
<Route path="/rules" component={RulesPage} />
<Route path="/dev" component={DevPage} />
return (
<div className={styles.root}>
<ScrollIntoView top />
<AuthFlowRoute
exact
path="/"
key="indexPage"
component={user.isGuest ? AuthPage : ProfileController}
/>
<AuthFlowRoute exact path="/code" component={DeviceCode} />
<AuthFlowRoute path="/" component={AuthPage} />
<div
id="view-port"
className={clsx(styles.viewPort, {
[styles.isPopupActive]: isPopupActive,
})}
>
<Toolbar account={account} onLogoClick={onLogoClick} />
<div className={styles.body}>
<React.Suspense fallback={<ComponentLoader />}>
<Switch>
<PrivateRoute path="/profile" component={ProfileController} />
<Route path="/404" component={PageNotFound} />
<Route path="/rules" component={RulesPage} />
<Route path="/dev" component={DevPage} />
<AuthFlowRoute
exact
path="/"
key="indexPage"
component={user.isGuest ? AuthPage : ProfileController}
/>
<AuthFlowRoute path="/" component={AuthPage} />
<Route component={PageNotFound} />
</Switch>
</React.Suspense>
</div>
<Route component={PageNotFound} />
</Switch>
</React.Suspense>
</div>
<PopupStack />
</div>
);
}
}
<PopupStack />
</div>
);
};
export default connect(
(state) => ({
user: state.user,
account: getActiveAccount(state),
isPopupActive: state.popup.popups.length > 0,
}),
{
onLogoClick: resetAuth,
},
)(RootPage);
export default RootPage;

View File

@@ -24,35 +24,27 @@ export interface OauthAppResponse {
minecraftServerIp?: string;
}
interface OauthRequestData {
interface AuthCodeFlowRequestData {
client_id: string;
redirect_uri: string;
response_type: string;
description?: string;
scope: string;
prompt: string;
login_hint?: string;
state?: string;
}
export interface OauthData {
clientId: string;
redirectUrl: string;
responseType: string;
description?: string;
scope: string;
// TODO: why prompt is not nullable?
prompt: string; // comma separated list of 'none' | 'consent' | 'select_account';
loginHint?: string;
state?: string;
interface DeviceCodeFlowRequestData {
user_code: string;
}
export type OauthRequestData = (AuthCodeFlowRequestData | DeviceCodeFlowRequestData) & {
description?: string;
};
export interface OAuthValidateResponse {
session: {
scopes: Scope[];
};
client: Client;
oAuth: {}; // TODO: improve typing
}
interface FormPayloads {
@@ -64,29 +56,30 @@ interface FormPayloads {
}
const api = {
validate(oauthData: OauthData) {
validate(oauthData: OauthRequestData) {
return request
.get<OAuthValidateResponse>('/api/oauth2/v1/validate', getOAuthRequest(oauthData))
.get<OAuthValidateResponse>('/api/oauth2/v1/validate', oauthData)
.catch(handleOauthParamsValidation);
},
complete(
oauthData: OauthData,
oauthData: OauthRequestData,
params: { accept?: boolean } = {},
): Promise<{
success: boolean;
redirectUri: string;
redirectUri?: string;
}> {
const query = request.buildQuery(getOAuthRequest(oauthData));
const data: Record<string, any> = {};
if (typeof params.accept !== 'undefined') {
data.accept = params.accept;
}
return request
.post<{
success: boolean;
redirectUri: string;
}>(
`/api/oauth2/v1/complete?${query}`,
typeof params.accept === 'undefined' ? {} : { accept: params.accept },
)
}>(`/api/oauth2/v1/complete?${request.buildQuery(oauthData)}`, data)
.catch((resp = {}) => {
if (resp.statusCode === 401 && resp.error === 'access_denied') {
// user declined permissions
@@ -146,37 +139,18 @@ if ('Cypress' in window) {
export default api;
/**
* @param {object} oauthData
* @param {string} oauthData.clientId
* @param {string} oauthData.redirectUrl
* @param {string} oauthData.responseType
* @param {string} oauthData.description
* @param {string} oauthData.scope
* @param {string} oauthData.state
*
* @returns {object}
*/
function getOAuthRequest(oauthData: OauthData): OauthRequestData {
return {
client_id: oauthData.clientId,
redirect_uri: oauthData.redirectUrl,
response_type: oauthData.responseType,
description: oauthData.description,
scope: oauthData.scope,
prompt: oauthData.prompt,
login_hint: oauthData.loginHint,
state: oauthData.state,
};
}
function handleOauthParamsValidation(
resp:
| { [key: string]: any }
| Record<string, any>
| {
statusCode: number;
success: false;
error: 'invalid_request' | 'unsupported_response_type' | 'invalid_scope' | 'invalid_client';
error:
| 'invalid_request'
| 'unsupported_response_type'
| 'invalid_scope'
| 'invalid_client'
| 'invalid_user_code';
parameter: string;
} = {},
) {

View File

@@ -1,5 +1,3 @@
import logger from 'app/services/logger';
import AbstractState from './AbstractState';
import { AuthContext } from './AuthFlow';
import CompleteState from './CompleteState';
@@ -16,10 +14,7 @@ export default class AcceptRulesState extends AbstractState {
}
resolve(context: AuthContext): Promise<void> | void {
context
.run('acceptRules')
.then(() => context.setState(new CompleteState()))
.catch((err = {}) => err.errors || logger.warn('Error accepting rules', err));
return context.run('acceptRules').then(() => context.setState(new CompleteState()));
}
reject(context: AuthContext, payload: Record<string, any>): void {

View File

@@ -1,4 +1,3 @@
import logger from 'app/services/logger';
import { AuthContext } from 'app/services/authFlow';
import AbstractState from './AbstractState';
@@ -12,10 +11,7 @@ export default class ActivationState extends AbstractState {
}
resolve(context: AuthContext, payload: { key: string }): Promise<void> | void {
context
.run('activate', payload.key)
.then(() => context.setState(new CompleteState()))
.catch((err = {}) => err.errors || logger.warn('Error activating account', err));
return context.run('activate', payload.key).then(() => context.setState(new CompleteState()));
}
reject(context: AuthContext): void {

View File

@@ -1,5 +1,6 @@
import expect from 'app/test/unexpected';
import sinon from 'sinon';
import { SynchronousPromise } from 'synchronous-promise';
import { Store } from 'redux';
@@ -100,21 +101,22 @@ describe('AuthFlow.functional', () => {
auth: {
credentials: {},
oauth: {
clientId: 123,
params: {
clientId: 123,
},
prompt: [],
},
},
});
// @ts-ignore
flow.run.onCall(0).returns({ then: (fn) => fn() });
flow.run.onCall(0).returns(SynchronousPromise.resolve());
// @ts-ignore
flow.run.onCall(1).returns({
then: (fn: Function) =>
fn({
redirectUri: expectedRedirect,
}),
});
flow.run.onCall(1).returns(
SynchronousPromise.resolve({
redirectUri: expectedRedirect,
}),
);
navigate('/oauth2');

View File

@@ -5,7 +5,7 @@ import AuthFlow from 'app/services/authFlow/AuthFlow';
import AbstractState from 'app/services/authFlow/AbstractState';
import localStorage from 'app/services/localStorage';
import OAuthState from 'app/services/authFlow/OAuthState';
import InitOAuthAuthCodeFlowState from './InitOAuthAuthCodeFlowState';
import RegisterState from 'app/services/authFlow/RegisterState';
import RecoverPasswordState from 'app/services/authFlow/RecoverPasswordState';
import ForgotPasswordState from 'app/services/authFlow/ForgotPasswordState';
@@ -14,6 +14,7 @@ import ResendActivationState from 'app/services/authFlow/ResendActivationState';
import LoginState from 'app/services/authFlow/LoginState';
import CompleteState from 'app/services/authFlow/CompleteState';
import ChooseAccountState from 'app/services/authFlow/ChooseAccountState';
import FinishState from 'app/services/authFlow/FinishState';
import { Store } from 'redux';
describe('AuthFlow', () => {
@@ -211,11 +212,6 @@ describe('AuthFlow', () => {
expect(actual, 'to be', expected);
});
it('should throw if no state', () => {
// @ts-ignore
expect(() => flow.setState(), 'to throw', 'State is required');
});
});
describe('#run', () => {
@@ -313,10 +309,10 @@ describe('AuthFlow', () => {
'/accept-rules': LoginState,
'/oauth/permissions': LoginState,
'/oauth/choose-account': LoginState,
'/oauth/finish': LoginState,
'/oauth2/v1/foo': OAuthState,
'/oauth2/v1': OAuthState,
'/oauth2': OAuthState,
'/oauth/finish': FinishState,
'/oauth2/v1/foo': InitOAuthAuthCodeFlowState,
'/oauth2/v1': InitOAuthAuthCodeFlowState,
'/oauth2': InitOAuthAuthCodeFlowState,
'/register': RegisterState,
'/choose-account': ChooseAccountState,
'/recover-password': RecoverPasswordState,
@@ -379,7 +375,7 @@ describe('AuthFlow', () => {
flow.handleRequest({ path, query: new URLSearchParams({}), params: {} }, () => {}, callback);
expect(flow.setState, 'was called once');
expect(flow.setState, 'to have a call satisfying', [expect.it('to be a', OAuthState)]);
expect(flow.setState, 'to have a call satisfying', [expect.it('to be a', InitOAuthAuthCodeFlowState)]);
expect(callback, 'was called twice');
});

View File

@@ -10,10 +10,13 @@ import {
} from 'app/components/accounts/actions';
import * as actions from 'app/components/auth/actions';
import { updateUser } from 'app/components/user/actions';
import dispatchBsod from 'app/components/ui/bsod/dispatchBsod';
import FinishState from './FinishState';
import RegisterState from './RegisterState';
import LoginState from './LoginState';
import OAuthState from './OAuthState';
import InitOAuthAuthCodeFlowState from './InitOAuthAuthCodeFlowState';
import InitOAuthDeviceCodeFlowState from './InitOAuthDeviceCodeFlowState';
import ForgotPasswordState from './ForgotPasswordState';
import RecoverPasswordState from './RecoverPasswordState';
import ActivationState from './ActivationState';
@@ -22,11 +25,11 @@ import ChooseAccountState from './ChooseAccountState';
import ResendActivationState from './ResendActivationState';
import State from './State';
type Request = {
interface Request {
path: string;
query: URLSearchParams;
params: Record<string, any>;
};
}
export const availableActions = {
updateUser,
@@ -104,7 +107,11 @@ export default class AuthFlow implements AuthContext {
this.replace(route);
}
browserHistory[options.replace ? 'replace' : 'push'](route);
if (options.replace) {
browserHistory.replace(route);
} else {
browserHistory.push(route);
}
}
this.replace = null;
@@ -115,11 +122,23 @@ export default class AuthFlow implements AuthContext {
}
resolve(payload: Record<string, any> = {}) {
this.state.resolve(this, payload);
const maybePromise = this.state.resolve(this, payload);
if (maybePromise && maybePromise.catch) {
maybePromise.catch((err) => {
dispatchBsod();
throw err;
});
}
}
reject(payload: Record<string, any> = {}) {
this.state.reject(this, payload);
try {
this.state.reject(this, payload);
} catch (err) {
dispatchBsod();
throw err;
}
}
goBack() {
@@ -127,16 +146,12 @@ export default class AuthFlow implements AuthContext {
}
run<T extends ActionId>(actionId: T, payload?: Parameters<typeof availableActions[T]>[0]): Promise<any> {
// @ts-ignore the extended version of redux with thunk will return the correct promise
// @ts-expect-error the extended version of redux with thunk will return the correct promise
return Promise.resolve(this.dispatch(this.actions[actionId](payload)));
}
setState(state: State) {
if (!state) {
throw new Error('State is required');
}
this.state && this.state.leave(this);
setState(state: State): Promise<void> | void {
this.state?.leave(this);
this.prevState = this.state;
this.state = state;
const resp = this.state.enter(this);
@@ -170,16 +185,8 @@ export default class AuthFlow implements AuthContext {
/**
* This should be called from onEnter prop of react-router Route component
*
* @param {object} request
* @param {string} request.path
* @param {object} request.params
* @param {URLSearchParams} request.query
* @param {Function} replace
* @param {Function} [callback=function() {}] - an optional callback function to be called, when state will be stabilized
* (state's enter function's promise resolved)
*/
handleRequest(request: Request, replace: (path: string) => void, callback: () => void = () => {}) {
handleRequest(request: Request, replace: (path: string) => void, callback: () => void = () => {}): void {
const { path } = request;
this.replace = replace;
this.onReady = callback;
@@ -218,13 +225,20 @@ export default class AuthFlow implements AuthContext {
this.setState(new ChooseAccountState());
break;
case '/code':
this.setState(new InitOAuthDeviceCodeFlowState());
break;
case '/oauth/finish':
this.setState(new FinishState());
break;
case '/':
case '/login':
case '/password':
case '/mfa':
case '/accept-rules':
case '/oauth/permissions':
case '/oauth/finish':
case '/oauth/choose-account':
this.setState(new LoginState());
break;
@@ -234,7 +248,7 @@ export default class AuthFlow implements AuthContext {
path.replace(/(.)\/.+/, '$1') // use only first part of an url
) {
case '/oauth2':
this.setState(new OAuthState());
this.setState(new InitOAuthAuthCodeFlowState());
break;
case '/activation':
this.setState(new ActivationState());
@@ -255,8 +269,6 @@ export default class AuthFlow implements AuthContext {
/**
* Tries to restore last oauth request, if it was stored in localStorage
* in last 2 hours
*
* @returns {bool} - whether oauth state is being restored
*/
private restoreOAuthState(): boolean {
if (this.oAuthStateRestored) {

View File

@@ -1,4 +1,5 @@
import type { Account } from 'app/components/accounts/reducer';
import logger from 'app/services/logger';
import AbstractState from './AbstractState';
import { AuthContext } from './AuthFlow';
@@ -21,10 +22,16 @@ export default class ChooseAccountState extends AbstractState {
// So if there is no `id` property, it's an empty object
resolve(context: AuthContext, payload: Account): Promise<void> | void {
if (payload.id) {
return context
.run('authenticate', payload)
.then(() => context.run('setAccountSwitcher', false))
.then(() => context.setState(new CompleteState()));
return (
context
.run('authenticate', payload)
.then(() => context.run('setAccountSwitcher', false))
.then(() => context.setState(new CompleteState()))
// By default, this error must cause a BSOD. But by I don't know why reasons it shouldn't,
// because somebody somewhere catches an invalid authentication result and routes the user
// to the password entering form. To keep this behavior we catch all errors, log it and suppress
.catch((err) => err.errors || logger.warn('Error choosing an account', err))
);
}
// log in to another account

View File

@@ -1,5 +1,6 @@
import expect from 'app/test/unexpected';
import sinon, { SinonMock } from 'sinon';
import { SynchronousPromise } from 'synchronous-promise';
import CompleteState from 'app/services/authFlow/CompleteState';
import LoginState from 'app/services/authFlow/LoginState';
@@ -137,7 +138,9 @@ describe('CompleteState', () => {
auth: {
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
code: 'XXX',
},
},
@@ -157,7 +160,9 @@ describe('CompleteState', () => {
auth: {
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
acceptRequired: true,
},
},
@@ -176,7 +181,9 @@ describe('CompleteState', () => {
auth: {
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
prompt: ['consent'],
},
},
@@ -202,7 +209,9 @@ describe('CompleteState', () => {
credentials: {},
isSwitcherEnabled: true,
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
prompt: [],
},
},
@@ -227,7 +236,9 @@ describe('CompleteState', () => {
isSwitcherEnabled: true,
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
prompt: [],
},
},
@@ -251,13 +262,15 @@ describe('CompleteState', () => {
isSwitcherEnabled: false,
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
prompt: [],
},
},
});
expectRun(mock, 'oAuthComplete', {}).returns({ then() {} });
expectRun(mock, 'oAuthComplete', {}).returns(Promise.resolve());
state.enter(context);
});
@@ -275,7 +288,9 @@ describe('CompleteState', () => {
isSwitcherEnabled: true,
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
prompt: ['select_account'],
},
},
@@ -299,13 +314,15 @@ describe('CompleteState', () => {
isSwitcherEnabled: false,
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
prompt: ['select_account'],
},
},
});
expectRun(mock, 'oAuthComplete', {}).returns({ then() {} });
expectRun(mock, 'oAuthComplete', {}).returns(Promise.resolve());
state.enter(context);
});
@@ -322,39 +339,15 @@ describe('CompleteState', () => {
auth: {
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
prompt: [],
},
},
});
expectRun(mock, 'oAuthComplete', sinon.match.object).returns({
then() {},
});
state.enter(context);
});
it('should listen for auth success/failure', () => {
context.getState.returns({
user: {
isGuest: false,
},
auth: {
credentials: {},
oauth: {
clientId: 'ely.by',
prompt: [],
},
},
});
expectRun(mock, 'oAuthComplete', sinon.match.object).returns({
then(success: Function, fail: Function) {
expect(success, 'to be a', 'function');
expect(fail, 'to be a', 'function');
},
});
expectRun(mock, 'oAuthComplete', sinon.match.object).returns(Promise.resolve());
state.enter(context);
});
@@ -370,7 +363,9 @@ describe('CompleteState', () => {
auth: {
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
prompt: [],
},
},
@@ -389,8 +384,7 @@ describe('CompleteState', () => {
resp: Record<string, any>,
expectedInstance: typeof AbstractState,
) => {
// @ts-ignore
const promise = Promise[type](resp);
const promise = SynchronousPromise[type](resp);
context.getState.returns({
user: {
@@ -399,7 +393,9 @@ describe('CompleteState', () => {
auth: {
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
prompt: [],
},
},
@@ -447,7 +443,9 @@ describe('CompleteState', () => {
auth: {
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
loginHint: account[field],
prompt: [],
},
@@ -485,7 +483,9 @@ describe('CompleteState', () => {
auth: {
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
loginHint: account.id,
prompt: [],
},
@@ -493,9 +493,7 @@ describe('CompleteState', () => {
});
expectRun(mock, 'setAccountSwitcher', false);
expectRun(mock, 'oAuthComplete', {}).returns({
then: () => Promise.resolve(),
});
expectRun(mock, 'oAuthComplete', {}).returns(SynchronousPromise.resolve());
return expect(state.enter(context), 'to be fulfilled');
});
@@ -518,16 +516,16 @@ describe('CompleteState', () => {
auth: {
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
loginHint: account.id,
prompt: [],
},
},
});
expectRun(mock, 'oAuthComplete', {}).returns({
then: () => Promise.resolve(),
});
expectRun(mock, 'oAuthComplete', {}).returns(Promise.resolve());
return expect(state.enter(context), 'to be fulfilled');
});
@@ -560,16 +558,15 @@ describe('CompleteState', () => {
auth: {
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
prompt: [],
},
},
});
mock.expects('run')
.once()
.withExactArgs('oAuthComplete', sinon.match(expected))
.returns({ then() {} });
mock.expects('run').once().withExactArgs('oAuthComplete', sinon.match(expected)).returns(Promise.resolve());
state.enter(context);
});
@@ -585,15 +582,15 @@ describe('CompleteState', () => {
auth: {
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
prompt: [],
},
},
});
expectRun(mock, 'oAuthComplete', sinon.match(expected)).returns({
then() {},
});
expectRun(mock, 'oAuthComplete', sinon.match(expected)).returns(Promise.resolve());
state.enter(context);
});
@@ -611,16 +608,16 @@ describe('CompleteState', () => {
auth: {
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
prompt: [],
acceptRequired: true,
},
},
});
expectRun(mock, 'oAuthComplete', sinon.match(expected)).returns({
then() {},
});
expectRun(mock, 'oAuthComplete', sinon.match(expected)).returns(Promise.resolve());
state.enter(context);
});
@@ -638,16 +635,16 @@ describe('CompleteState', () => {
auth: {
credentials: {},
oauth: {
clientId: 'ely.by',
params: {
clientId: 'ely.by',
},
prompt: [],
acceptRequired: true,
},
},
});
expectRun(mock, 'oAuthComplete', sinon.match(expected)).returns({
then() {},
});
expectRun(mock, 'oAuthComplete', sinon.match(expected)).returns(Promise.resolve());
state.enter(context);
});

View File

@@ -1,4 +1,6 @@
import { OAuthState } from 'app/components/auth/reducer';
import { getActiveAccount } from 'app/components/accounts/reducer';
import AbstractState from './AbstractState';
import LoginState from './LoginState';
import PermissionsState from './PermissionsState';
@@ -11,8 +13,16 @@ import { AuthContext } from './AuthFlow';
const PROMPT_ACCOUNT_CHOOSE = 'select_account';
const PROMPT_PERMISSIONS = 'consent';
function hasPrompt(prompt: OAuthState['prompt'], needle: string): boolean {
if (Array.isArray(prompt)) {
return prompt.includes(needle);
}
return prompt === needle;
}
export default class CompleteState extends AbstractState {
isPermissionsAccepted: boolean | void;
isPermissionsAccepted?: boolean;
constructor(
options: {
@@ -36,7 +46,7 @@ export default class CompleteState extends AbstractState {
context.setState(new LoginState());
} else if (user.shouldAcceptRules && !user.isDeleted) {
context.setState(new AcceptRulesState());
} else if (oauth && oauth.clientId) {
} else if (oauth?.params) {
return this.processOAuth(context);
} else {
context.navigate('/');
@@ -80,47 +90,51 @@ export default class CompleteState extends AbstractState {
// so that they can see, that their account was deleted
// (this info is displayed on switcher)
user.isDeleted ||
oauth.prompt.includes(PROMPT_ACCOUNT_CHOOSE))
hasPrompt(oauth.prompt, PROMPT_ACCOUNT_CHOOSE))
) {
context.setState(new ChooseAccountState());
} else if (user.isDeleted) {
return context.setState(new ChooseAccountState());
}
if (user.isDeleted) {
// you shall not pass
// if we are here, this means that user have already seen account
// switcher and now we should redirect him to his profile,
// because oauth is not available for deleted accounts
context.navigate('/');
} else if (oauth.code) {
context.setState(new FinishState());
} else {
const data: { [key: string]: any } = {};
if (typeof this.isPermissionsAccepted !== 'undefined') {
data.accept = this.isPermissionsAccepted;
} else if (oauth.acceptRequired || oauth.prompt.includes(PROMPT_PERMISSIONS)) {
context.setState(new PermissionsState());
return;
}
// TODO: it seems that oAuthComplete may be a separate state
return context.run('oAuthComplete', data).then(
(resp: { redirectUri: string }) => {
// TODO: пусть в стейт попадает флаг или тип авторизации
// вместо волшебства над редирект урлой
if (resp.redirectUri.includes('static_page')) {
context.setState(new FinishState());
} else {
return context.run('redirect', resp.redirectUri);
}
},
(resp) => {
if (resp.unauthorized) {
context.setState(new LoginState());
} else if (resp.acceptRequired) {
context.setState(new PermissionsState());
}
},
);
return context.navigate('/');
}
if (oauth.code) {
return context.setState(new FinishState());
}
const data: Record<string, any> = {};
if (typeof this.isPermissionsAccepted !== 'undefined') {
data.accept = this.isPermissionsAccepted;
} else if (oauth.acceptRequired || hasPrompt(oauth.prompt, PROMPT_PERMISSIONS)) {
context.setState(new PermissionsState());
return;
}
// TODO: it seems that oAuthComplete may be a separate state
return context
.run('oAuthComplete', data)
.then((resp: { redirectUri?: string }) => {
// TODO: пусть в стейт попадает флаг или тип авторизации
// вместо волшебства над редирект урлой
if (!resp.redirectUri || resp.redirectUri.includes('static_page')) {
context.setState(new FinishState());
} else {
return context.run('redirect', resp.redirectUri);
}
})
.catch((resp) => {
if (resp.unauthorized) {
context.setState(new LoginState());
} else if (resp.acceptRequired) {
context.setState(new PermissionsState());
}
});
}
}

View File

@@ -0,0 +1,26 @@
import AbstractState from './AbstractState';
import { AuthContext } from './AuthFlow';
import CompleteState from './CompleteState';
export default class DeviceCodeState extends AbstractState {
async resolve(context: AuthContext, payload: { user_code: string }): Promise<void> {
const { query } = context.getRequest();
return context
.run('oAuthValidate', {
params: {
userCode: payload.user_code,
},
description: query.get('description')!,
prompt: query.get('prompt')!,
})
.then(() => context.setState(new CompleteState()))
.catch((err) => {
if (['invalid_user_code', 'expired_token', 'used_user_code'].includes(err.error)) {
return context.run('setErrors', { [err.parameter]: err.error });
}
throw err;
});
}
}

View File

@@ -1,5 +1,3 @@
import logger from 'app/services/logger';
import AbstractState from './AbstractState';
import { AuthContext } from './AuthFlow';
import LoginState from './LoginState';
@@ -11,13 +9,10 @@ export default class ForgotPasswordState extends AbstractState {
}
resolve(context: AuthContext, payload: { login: string; captcha: string }): Promise<void> | void {
context
.run('forgotPassword', payload)
.then(() => {
context.run('setLogin', payload.login);
context.setState(new RecoverPasswordState());
})
.catch((err = {}) => err.errors || logger.warn('Error requesting password recoverage', err));
return context.run('forgotPassword', payload).then(() => {
context.run('setLogin', payload.login);
context.setState(new RecoverPasswordState());
});
}
goBack(context: AuthContext): void {

View File

@@ -1,17 +1,17 @@
import sinon, { SinonMock } from 'sinon';
import OAuthState from 'app/services/authFlow/OAuthState';
import InitOAuthAuthCodeFlowState from './InitOAuthAuthCodeFlowState';
import CompleteState from 'app/services/authFlow/CompleteState';
import { bootstrap, expectState, expectRun, MockedAuthContext } from './helpers';
describe('OAuthState', () => {
let state: OAuthState;
let state: InitOAuthAuthCodeFlowState;
let context: MockedAuthContext;
let mock: SinonMock;
beforeEach(() => {
state = new OAuthState();
state = new InitOAuthAuthCodeFlowState();
const data = bootstrap();
context = data.context;
@@ -44,14 +44,16 @@ describe('OAuthState', () => {
mock,
'oAuthValidate',
sinon.match({
clientId: query.client_id,
redirectUrl: query.redirect_uri,
responseType: query.response_type,
params: {
clientId: query.client_id,
redirectUrl: query.redirect_uri,
responseType: query.response_type,
scope: query.scope,
state: query.state,
},
description: query.description,
scope: query.scope,
prompt: query.prompt,
loginHint: query.login_hint,
state: query.state,
}),
).returns({ then() {} });
@@ -76,11 +78,13 @@ describe('OAuthState', () => {
mock,
'oAuthValidate',
sinon.match({
clientId,
redirectUrl: query.redirect_uri,
responseType: query.response_type,
scope: query.scope,
state: query.state,
params: {
clientId,
redirectUrl: query.redirect_uri,
responseType: query.response_type,
scope: query.scope,
state: query.state,
},
}),
).returns({ then() {} });
@@ -106,11 +110,13 @@ describe('OAuthState', () => {
mock,
'oAuthValidate',
sinon.match({
clientId: query.client_id,
redirectUrl: query.redirect_uri,
responseType: query.response_type,
scope: query.scope,
state: query.state,
params: {
clientId: query.client_id,
redirectUrl: query.redirect_uri,
responseType: query.response_type,
scope: query.scope,
state: query.state,
},
}),
).returns({ then() {} });
@@ -134,11 +140,13 @@ describe('OAuthState', () => {
mock,
'oAuthValidate',
sinon.match({
clientId: query.client_id,
redirectUrl: query.redirect_uri,
responseType: query.response_type,
scope: 'scope1 scope2 scope3',
state: query.state,
params: {
clientId: query.client_id,
redirectUrl: query.redirect_uri,
responseType: query.response_type,
scope: 'scope1 scope2 scope3',
state: query.state,
},
}),
).returns({ then() {} });

View File

@@ -2,20 +2,22 @@ import AbstractState from './AbstractState';
import { AuthContext } from './AuthFlow';
import CompleteState from './CompleteState';
export default class OAuthState extends AbstractState {
export default class InitOAuthAuthCodeFlowState extends AbstractState {
enter(context: AuthContext): Promise<void> | void {
const { query, params } = context.getRequest();
return context
.run('oAuthValidate', {
clientId: query.get('client_id') || params.clientId,
redirectUrl: query.get('redirect_uri')!,
responseType: query.get('response_type')!,
params: {
clientId: query.get('client_id') || params.clientId,
redirectUrl: query.get('redirect_uri')!,
responseType: query.get('response_type')!,
scope: (query.get('scope') || '').replace(/,/g, ' '),
state: query.get('state')!,
},
description: query.get('description')!,
scope: (query.get('scope') || '').replace(/,/g, ' '),
prompt: query.get('prompt')!,
loginHint: query.get('login_hint')!,
state: query.get('state')!,
})
.then(() => context.setState(new CompleteState()));
}

View File

@@ -0,0 +1,28 @@
import AbstractState from './AbstractState';
import { AuthContext } from './AuthFlow';
import CompleteState from './CompleteState';
import DeviceCodeState from './DeviceCodeState';
export default class InitOAuthDeviceCodeFlowState extends AbstractState {
async enter(context: AuthContext): Promise<void> {
const { query } = context.getRequest();
const userCode = query.get('user_code');
if (userCode) {
try {
await context.run('oAuthValidate', {
params: { userCode },
description: query.get('description')!,
prompt: query.get('prompt')!,
});
return context.setState(new CompleteState());
} catch {
// Ok, fallback to the default
}
}
return context.setState(new DeviceCodeState());
}
}

View File

@@ -30,7 +30,7 @@ export default class LoginState extends AbstractState {
login: string;
},
): Promise<void> | void {
context
return context
.run('login', payload)
.then(() => context.setState(new PasswordState()))
.catch((err = {}) => err.errors || logger.warn('Error validating login', err));

View File

@@ -12,15 +12,15 @@ export default class PermissionsState extends AbstractState {
}
resolve(context: AuthContext): Promise<void> | void {
this.process(context, true);
return this.process(context, true);
}
reject(context: AuthContext): void {
this.process(context, false);
}
process(context: AuthContext, accept: boolean): void {
context.setState(
process(context: AuthContext, accept: boolean): Promise<void> | void {
return context.setState(
new CompleteState({
accept,
}),

View File

@@ -1,5 +1,3 @@
import logger from 'app/services/logger';
import AbstractState from './AbstractState';
import { AuthContext } from './AuthFlow';
import LoginState from './LoginState';
@@ -17,10 +15,7 @@ export default class RecoverPasswordState extends AbstractState {
context: AuthContext,
payload: { key: string; newPassword: string; newRePassword: string },
): Promise<void> | void {
context
.run('recoverPassword', payload)
.then(() => context.setState(new CompleteState()))
.catch((err = {}) => err.errors || logger.warn('Error recovering password', err));
return context.run('recoverPassword', payload).then(() => context.setState(new CompleteState()));
}
goBack(context: AuthContext): void {

View File

@@ -1,5 +1,3 @@
import logger from 'app/services/logger';
import AbstractState from './AbstractState';
import { AuthContext } from './AuthFlow';
import CompleteState from './CompleteState';
@@ -22,10 +20,7 @@ export default class RegisterState extends AbstractState {
rulesAgreement: boolean;
},
): Promise<void> | void {
context
.run('register', payload)
.then(() => context.setState(new CompleteState()))
.catch((err = {}) => err.errors || logger.warn('Error registering', err));
return context.run('register', payload).then(() => context.setState(new CompleteState()));
}
reject(context: AuthContext, payload: Record<string, any>): void {

View File

@@ -1,5 +1,4 @@
import { AuthContext } from 'app/services/authFlow';
import logger from 'app/services/logger';
import AbstractState from './AbstractState';
import ActivationState from './ActivationState';
@@ -11,10 +10,7 @@ export default class ResendActivationState extends AbstractState {
}
resolve(context: AuthContext, payload: { email: string; captcha: string }): Promise<void> | void {
context
.run('resendActivation', payload)
.then(() => context.setState(new ActivationState()))
.catch((err = {}) => err.errors || logger.warn('Error resending activation', err));
return context.run('resendActivation', payload).then(() => context.setState(new ActivationState()));
}
reject(context: AuthContext): void {

View File

@@ -127,6 +127,15 @@ const errorsMap: Record<string, (props?: Record<string, any>) => ReactElement> =
'error.redirectUri_required': () => <Message key="redirectUriRequired" defaultMessage="Redirect URI is required" />,
'error.redirectUri_invalid': () => <Message key="redirectUriInvalid" defaultMessage="Redirect URI is invalid" />,
invalid_user_code: () => <Message key="invalidUserCode" defaultMessage="Invalid Device Code" />,
expired_token: () => (
<Message
key="expiredUserCode"
defaultMessage="The code has expired. Start the authorization flow in the application again."
/>
),
used_user_code: () => <Message key="usedUserCode" defaultMessage="This code has been already used" />,
};
interface ErrorLiteral {

View File

@@ -1,4 +1,5 @@
import { account1 } from '../../fixtures/accounts.json';
import { OAuthState } from 'app/components/auth/reducer';
import { UserResponse } from 'app/services/api/accounts';
const defaults = {
@@ -9,35 +10,175 @@ const defaults = {
};
describe('OAuth', () => {
it('should complete oauth', () => {
cy.login({ accounts: ['default'] });
describe('AuthCode grant flow', () => {
it('should complete oauth', () => {
cy.login({ accounts: ['default'] });
cy.visit(`/oauth2/v1/ely?${new URLSearchParams(defaults)}`);
cy.visit(`/oauth2/v1/ely?${new URLSearchParams(defaults)}`);
cy.url().should('equal', 'https://dev.ely.by/');
cy.url().should('equal', 'https://dev.ely.by/');
});
it('should restore previous oauthData if any', () => {
localStorage.setItem(
'oauthData',
JSON.stringify({
timestamp: Date.now() - 3600,
payload: {
params: {
clientId: 'ely',
redirectUrl: 'https://dev.ely.by/authorization/oauth',
responseType: 'code',
state: '',
scope: 'account_info account_email',
},
} as OAuthState,
}),
);
cy.login({ accounts: ['default'] });
cy.visit('/');
cy.url().should('equal', 'https://dev.ely.by/');
});
describe('static pages', () => {
it('should authenticate using static page', () => {
cy.server();
cy.route({
method: 'POST',
url: '/api/oauth2/v1/complete**',
}).as('complete');
cy.login({ accounts: ['default'] });
cy.visit(
`/oauth2/v1/ely?${new URLSearchParams({
...defaults,
client_id: 'tlauncher',
redirect_uri: 'static_page',
})}`,
);
cy.wait('@complete');
cy.url().should('include', 'oauth/finish#{%22auth_code%22:');
});
it('should authenticate using static page with code', () => {
cy.server();
cy.route({
method: 'POST',
url: '/api/oauth2/v1/complete**',
}).as('complete');
cy.login({ accounts: ['default'] });
cy.visit(
`/oauth2/v1/ely?${new URLSearchParams({
...defaults,
client_id: 'tlauncher',
redirect_uri: 'static_page_with_code',
})}`,
);
cy.wait('@complete');
cy.url().should('include', 'oauth/finish#{%22auth_code%22:');
cy.findByTestId('oauth-code-container').should('contain', 'provide the following code');
// just click on copy, but we won't assert if the string was copied
// because it is a little bit complicated
// https://github.com/cypress-io/cypress/issues/2752
cy.findByTestId('oauth-code-container').contains('Copy').click();
});
});
});
it('should restore previous oauthData if any', () => {
localStorage.setItem(
'oauthData',
JSON.stringify({
timestamp: Date.now() - 3600,
payload: {
clientId: 'ely',
redirectUrl: 'https://dev.ely.by/authorization/oauth',
responseType: 'code',
description: null,
scope: 'account_info account_email',
loginHint: null,
state: null,
},
}),
);
cy.login({ accounts: ['default'] });
describe('DeviceCode grant flow', () => {
it('should complete flow by complete uri', () => {
cy.login({ accounts: ['default'] });
cy.visit('/');
cy.visit('/code?user_code=E2E-APPROVED');
cy.url().should('equal', 'https://dev.ely.by/');
cy.location('pathname').should('eq', '/oauth/finish');
cy.get('[data-e2e-content]').contains('successfully completed');
});
it('should complete flow with manual approve', () => {
cy.login({ accounts: ['default'] });
cy.visit('/code');
cy.get('[name=user_code]').type('E2E-UNAPPROVED{enter}');
cy.location('pathname').should('eq', '/oauth/permissions');
cy.findByTestId('auth-controls').contains('Approve').click();
cy.location('pathname').should('eq', '/oauth/finish');
cy.get('[data-e2e-content]').contains('successfully completed');
});
it('should complete flow with auto approve', () => {
cy.login({ accounts: ['default'] });
cy.visit('/code');
cy.get('[name=user_code]').type('E2E-APPROVED{enter}');
cy.location('pathname').should('eq', '/oauth/finish');
cy.get('[data-e2e-content]').contains('successfully completed');
});
it('should complete flow by declining the code', () => {
cy.login({ accounts: ['default'] });
cy.visit('/code');
cy.get('[name=user_code]').type('E2E-UNAPPROVED{enter}');
cy.location('pathname').should('eq', '/oauth/permissions');
cy.findByTestId('auth-controls-secondary').contains('Decline').click();
cy.location('pathname').should('eq', '/oauth/finish');
cy.get('[data-e2e-content]').contains('was failed');
});
it('should show an error for an unknown code', () => {
cy.login({ accounts: ['default'] });
cy.visit('/code');
cy.get('[name=user_code]').type('UNKNOWN-CODE{enter}');
cy.location('pathname').should('eq', '/code');
cy.findByTestId('auth-error').contains('Invalid Device Code');
});
it('should show an error for an expired code', () => {
cy.login({ accounts: ['default'] });
cy.visit('/code');
cy.get('[name=user_code]').type('E2E-EXPIRED{enter}');
cy.location('pathname').should('eq', '/code');
cy.findByTestId('auth-error').contains('The code has expired');
});
it('should show an error for an expired code', () => {
cy.login({ accounts: ['default'] });
cy.visit('/code');
cy.get('[name=user_code]').type('E2E-COMPLETED{enter}');
cy.location('pathname').should('eq', '/code');
cy.findByTestId('auth-error').contains('This code has been already used');
});
});
describe('AccountSwitcher', () => {
@@ -81,6 +222,7 @@ describe('OAuth', () => {
...defaults,
client_id: 'tlauncher',
redirect_uri: 'http://localhost:8080',
state: '123',
})}`,
);
@@ -92,7 +234,7 @@ describe('OAuth', () => {
cy.findByTestId('auth-controls').contains('Approve').click();
cy.url().should('match', /^http:\/\/localhost:8080\/?\?code=[^&]+&state=$/);
cy.url().should('match', /^http:\/\/localhost:8080\/?\?code=[^&]+&state=123$/);
});
it('should redirect to error page, when permission request declined', () => {
@@ -334,7 +476,7 @@ describe('OAuth', () => {
cy.findByTestId('auth-controls').contains('Approve').click();
cy.url().should('match', /^http:\/\/localhost:8080\/?\?code=[^&]+&state=$/);
cy.url().should('match', /^http:\/\/localhost:8080\/?\?code=[^&]+$/);
});
it('should redirect to error page, when permission request declined', () => {
@@ -377,7 +519,7 @@ describe('OAuth', () => {
cy.findByTestId('auth-controls').contains('Approve').click();
cy.url().should('match', /^http:\/\/localhost:8080\/?\?code=[^&]+&state=$/);
cy.url().should('match', /^http:\/\/localhost:8080\/?\?code=[^&]+$/);
});
});
@@ -403,60 +545,7 @@ describe('OAuth', () => {
cy.findByTestId('auth-controls').contains('Approve').click();
cy.url().should('match', /^http:\/\/localhost:8080\/?\?code=[^&]+&state=$/);
});
});
describe('static pages', () => {
it('should authenticate using static page', () => {
cy.server();
cy.route({
method: 'POST',
url: '/api/oauth2/v1/complete**',
}).as('complete');
cy.login({ accounts: ['default'] });
cy.visit(
`/oauth2/v1/ely?${new URLSearchParams({
...defaults,
client_id: 'tlauncher',
redirect_uri: 'static_page',
})}`,
);
cy.wait('@complete');
cy.url().should('include', 'oauth/finish#{%22auth_code%22:');
});
it('should authenticate using static page with code', () => {
cy.server();
cy.route({
method: 'POST',
url: '/api/oauth2/v1/complete**',
}).as('complete');
cy.login({ accounts: ['default'] });
cy.visit(
`/oauth2/v1/ely?${new URLSearchParams({
...defaults,
client_id: 'tlauncher',
redirect_uri: 'static_page_with_code',
})}`,
);
cy.wait('@complete');
cy.url().should('include', 'oauth/finish#{%22auth_code%22:');
cy.findByTestId('oauth-code-container').should('contain', 'provide the following code');
// just click on copy, but we won't assert if the string was copied
// because it is a little bit complicated
// https://github.com/cypress-io/cypress/issues/2752
cy.findByTestId('oauth-code-container').contains('Copy').click();
cy.url().should('match', /^http:\/\/localhost:8080\/?\?code=[^&]+$/);
});
});
});

View File

@@ -13829,6 +13829,11 @@ symbol.prototype.description@^1.0.0:
has-symbols "^1.0.1"
object.getownpropertydescriptors "^2.1.2"
synchronous-promise@^2.0.17:
version "2.0.17"
resolved "https://registry.yarnpkg.com/synchronous-promise/-/synchronous-promise-2.0.17.tgz#38901319632f946c982152586f2caf8ddc25c032"
integrity sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==
table@^6.0.9:
version "6.7.1"
resolved "https://registry.yarnpkg.com/table/-/table-6.7.1.tgz#ee05592b7143831a8c94f3cee6aae4c1ccef33e2"