mirror of
https://github.com/elyby/accounts-frontend.git
synced 2025-05-31 14:11:58 +05:30
Merge branch 'device_code'
This commit is contained in:
@@ -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;
|
||||
|
@@ -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;
|
||||
|
@@ -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=',
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
@@ -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));
|
||||
};
|
||||
}
|
||||
|
||||
|
@@ -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>
|
||||
);
|
||||
|
32
packages/app/components/auth/deviceCode/DeviceCode.tsx
Normal file
32
packages/app/components/auth/deviceCode/DeviceCode.tsx
Normal 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;
|
43
packages/app/components/auth/deviceCode/DeviceCodeBody.tsx
Normal file
43
packages/app/components/auth/deviceCode/DeviceCodeBody.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
22
packages/app/components/auth/deviceCode/deviceCode.scss
Normal file
22
packages/app/components/auth/deviceCode/deviceCode.scss
Normal 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;
|
||||
}
|
1
packages/app/components/auth/deviceCode/index.ts
Normal file
1
packages/app/components/auth/deviceCode/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './DeviceCode';
|
@@ -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;
|
||||
|
1
packages/app/components/auth/finish/index.ts
Normal file
1
packages/app/components/auth/finish/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Finish';
|
@@ -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;
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
@@ -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();
|
||||
|
@@ -76,6 +76,7 @@ $bodyTopBottomPadding: 15px;
|
||||
padding: 10px 20px;
|
||||
margin: (-$bodyTopBottomPadding) (-$bodyLeftRightPadding) 15px;
|
||||
max-height: 200px;
|
||||
text-align: center;
|
||||
|
||||
transition: 0.4s ease;
|
||||
}
|
||||
|
@@ -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;
|
||||
|
@@ -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);
|
||||
});
|
||||
});
|
@@ -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;
|
||||
|
1
packages/app/hooks/index.ts
Normal file
1
packages/app/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as useIsMounted } from './useIsMounted';
|
17
packages/app/hooks/useIsMounted.ts
Normal file
17
packages/app/hooks/useIsMounted.ts
Normal 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, []);
|
||||
}
|
3
packages/app/icons/webfont/hash.svg
Normal file
3
packages/app/icons/webfont/hash.svg
Normal 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 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
@@ -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) => (
|
||||
|
@@ -14,8 +14,6 @@ $sidebar-width: 320px;
|
||||
}
|
||||
|
||||
.hiddenSidebar {
|
||||
composes: sidebar;
|
||||
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
@@ -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;
|
||||
|
@@ -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;
|
||||
} = {},
|
||||
) {
|
||||
|
@@ -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 {
|
||||
|
@@ -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 {
|
||||
|
@@ -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');
|
||||
|
||||
|
@@ -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');
|
||||
});
|
||||
|
||||
|
@@ -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) {
|
||||
|
@@ -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
|
||||
|
@@ -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);
|
||||
});
|
||||
|
@@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
26
packages/app/services/authFlow/DeviceCodeState.ts
Normal file
26
packages/app/services/authFlow/DeviceCodeState.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
@@ -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 {
|
||||
|
@@ -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() {} });
|
||||
|
@@ -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()));
|
||||
}
|
@@ -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());
|
||||
}
|
||||
}
|
@@ -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));
|
||||
|
@@ -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,
|
||||
}),
|
||||
|
@@ -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 {
|
||||
|
@@ -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 {
|
||||
|
@@ -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 {
|
||||
|
@@ -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 {
|
||||
|
@@ -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=[^&]+$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -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"
|
||||
|
Reference in New Issue
Block a user