Extract device code into a separate view.

Convert more components from class components to functional.
Fix invalid finish state when client was auto approved
This commit is contained in:
ErickSkrauch
2024-12-14 13:16:29 +01:00
parent 3f0565e26b
commit af59cc033f
20 changed files with 241 additions and 315 deletions

View File

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

View File

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

View File

@@ -87,6 +87,9 @@ describe('components/auth/actions', () => {
[setClient(resp.client)],
[
setOAuthRequest({
params: {
userCode: '',
},
prompt: 'none',
loginHint: undefined,
}),

View File

@@ -382,7 +382,12 @@ export function oAuthComplete(params: { accept?: boolean } = {}) {
localStorage.removeItem('oauthData');
if (!resp.redirectUri) {
dispatch(setOAuthCode({ success: resp.success && params.accept }));
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');

View File

@@ -1,18 +1,32 @@
import React from 'react';
import React, { FC } from 'react';
import { RouteComponentProps } from 'react-router-dom';
import { FormattedMessage as Message, defineMessages } from 'react-intl';
import factory from '../factory';
import Body from './DeviceCodeBody';
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',
deviceCodeTitle: 'Device Code',
});
export default factory({
title: messages.deviceCodeTitle,
body: Body,
footer: {
color: 'green',
children: <Message key="continueButton" defaultMessage="Continue" />,
},
});
const DeviceCode: FC<RouteComponentProps> = (props) => {
return (
<PanelTransition
key="deviceCode"
className={style.form}
Title={<AuthTitle title={messages.deviceCodeTitle} />}
Body={<DeviceCodeBody {...props} />}
Footer={
<Button type="submit">
<Message id="continue" defaultMessage="Cotinute" />
</Button>
}
/>
);
};
export default DeviceCode;

View File

@@ -1,13 +1,9 @@
import React from 'react';
import { defineMessages } from 'react-intl';
import { FormattedMessage as Message } from 'react-intl';
import { Input } from 'app/components/ui/form';
import BaseAuthBody from 'app/components/auth/BaseAuthBody';
const messages = defineMessages({
deviceCode: 'Device code',
});
export default class DeviceCodeBody extends BaseAuthBody {
static displayName = 'DeviceCodeBody';
static panelId = 'deviceCode';
@@ -16,16 +12,22 @@ export default class DeviceCodeBody extends BaseAuthBody {
render() {
return (
<div>
<>
{this.renderErrors()}
<Input
{...this.bindField('user_code')}
icon="key"
required
placeholder={messages.deviceCode}
/>
</div>
<Message id="deviceCode" defaultMessage="Device Code">
{(nodes) => (
<Input
{...this.bindField('user_code')}
icon="key"
name="user_cide"
autoFocus
required
placeholder={nodes as string}
/>
)}
</Message>
</>
);
}
}

View File

@@ -0,0 +1,5 @@
.form {
max-width: 340px;
margin: 0 auto;
padding: 55px 13px 0;
}

View File

@@ -1,121 +1,91 @@
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 }) => {
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,
})}
>
{type === 'error' && <span className={styles.close} onClick={handleCloseClick} />}
{children}
</div>
);
};
let close;
if (type === 'error') {
close = <span className={styles.close} onClick={this.onClose} />;
}
const className = clsx(styles[`${type}BodyHeader`], {
[styles.isClosed]: this.state.isClosed,
});
const extraProps = omit(this.props, ['type', 'onClose']);
return (
<div className={className} {...extraProps}>
{close}
{children}
</div>
);
}
onClose = (event: React.MouseEvent<HTMLElement>) => {
event.preventDefault();
const { onClose } = this.props;
this.setState({ isClosed: true });
if (onClose) {
onClose();
}
};
interface PanelIconProps {
icon: string;
}
export function PanelIcon({ icon }: { icon: string }) {
export const PanelIcon: FC<PanelIconProps> = ({ icon }) => {
return (
<div className={styles.panelIcon}>
<span className={icons[icon]} />
</div>
);
}
};

View File

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

View File

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

View File

@@ -1,17 +1,16 @@
import React, { FC } 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';
// Make "component" prop required
type Props = Omit<RouteProps, 'component'> & Required<Pick<RouteProps, 'component'>>;
type Props = Omit<RouteProps, 'component'> & {
component: ComponentType<RouteComponentProps>;
};
const AuthFlowRoute: FC<Props> = ({ component: Component, ...props }) => {
return (
<Route
{...props}
render={(routerProps) => <AuthFlowRouteContents routerProps={routerProps} component={Component} />}
/>
<Route {...props} render={(routerProps) => <AuthFlowRouteContents component={Component} {...routerProps} />} />
);
};

View File

@@ -43,7 +43,7 @@ describe('AuthFlowRouteContents', () => {
(authFlow.handleRequest as any).callsArg(2);
render(<AuthFlowRouteContents routerProps={routerProps} component={Component} />);
render(<AuthFlowRouteContents component={Component} {...routerProps} />);
const component = screen.getByTestId('test-component');

View File

@@ -1,89 +1,34 @@
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> | React.ComponentType<any>;
routerProps: RouteComponentProps;
interface Props extends RouteComponentProps {
component: ComponentType<RouteComponentProps>;
}
interface State {
access: null | 'rejected' | 'allowed';
component: React.ReactElement | 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;
const AuthFlowRouteContents: FC<Props> = ({ component: WantedComponent, location, match, history }) => {
const isMounted = useIsMounted();
const [component, setComponent] = useState<ReactElement | null>(null);
useEffect(() => {
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 (isMounted()) {
setComponent(<WantedComponent history={history} location={location} match={match} />);
}
},
this.onRedirect.bind(this),
this.onRouteAllowed.bind(this, props),
);
}
}, [location.pathname, location.search]);
onRedirect(path: string) {
if (!this.mounted) {
return;
}
return component;
};
this.setState({
access: 'rejected',
component: <Redirect to={path} />,
});
}
onRouteAllowed(props: Props): void {
if (!this.mounted) {
return;
}
const { component: Component } = props;
this.setState({
access: 'allowed',
component: <Component {...props.routerProps} />,
});
}
}
export default AuthFlowRouteContents;

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
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';
@@ -7,7 +8,6 @@ import Register from 'app/components/auth/register/Register';
import Login from 'app/components/auth/login/Login';
import Permissions from 'app/components/auth/permissions/Permissions';
import ChooseAccount from 'app/components/auth/chooseAccount/ChooseAccount';
import DeviceCode from 'app/components/auth/deviceCode';
import Activation from 'app/components/auth/activation/Activation';
import ResendActivation from 'app/components/auth/resendActivation/ResendActivation';
import Password from 'app/components/auth/password/Password';
@@ -38,8 +38,8 @@ const AuthPage: FC = () => {
}, []);
return (
<div>
<div className={isSidebarHidden ? styles.hiddenSidebar : styles.sidebar}>
<>
<div className={clsx(styles.sidebar, { [styles.hiddenSidebar]: isSidebarHidden })}>
<AppInfo {...client} onGoToAuth={goToAuth} />
</div>
@@ -55,14 +55,13 @@ const AuthPage: FC = () => {
<Route path="/choose-account" render={renderPanelTransition(ChooseAccount)} />
<Route path="/oauth/choose-account" render={renderPanelTransition(ChooseAccount)} />
<Route path="/oauth/finish" component={Finish} />
<Route path="/code" component={renderPanelTransition(DeviceCode)} />
<Route path="/accept-rules" render={renderPanelTransition(AcceptRules)} />
<Route path="/forgot-password" render={renderPanelTransition(ForgotPassword)} />
<Route path="/recover-password/:key?" render={renderPanelTransition(RecoverPassword)} />
<Redirect to="/404" />
</Switch>
</div>
</div>
</>
);
};

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ function hasPrompt(prompt: OAuthState['prompt'], needle: string): boolean {
}
export default class CompleteState extends AbstractState {
private readonly isPermissionsAccepted?: boolean;
isPermissionsAccepted?: boolean;
constructor(
options: {
@@ -54,8 +54,6 @@ export default class CompleteState extends AbstractState {
}
processOAuth(context: AuthContext): Promise<void> | void {
console.log('process oauth', this.isPermissionsAccepted);
const { auth, accounts, user } = context.getState();
let { isSwitcherEnabled } = auth;

View File

@@ -128,7 +128,7 @@ 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" />,
invalid_user_code: () => <Message key="invalidUserCode" defaultMessage="Invalid Device Code" />,
};
interface ErrorLiteral {