feat: URL navigation in auth (#1603)

* bump to react 18 and install react-router-dom

* Upgrade to react 18 root

* update vite

* add cs api's

* convert state/auth to ts

* add client config context

* add auto discovery context

* add spec version context

* add auth flow context

* add background dot pattern css

* add promise utils

* init url based routing

* update auth route server path as effect

* add auth server hook

* always use server from discovery info in context

* login - WIP

* upgrade jotai to v2

* add atom with localStorage util

* add multi account sessions atom

* add default IGNORE res to auto discovery

* add error type in async callback hook

* handle password login error

* fix async callback hook

* allow password login

* Show custom server not allowed error in mxId login

* add sso login component

* add token login

* fix hardcoded m.login.password in login func

* update server input on url change

* Improve sso login labels

* update folds

* fix async callback batching state update in safari

* wrap async callback set state in queueMicrotask

* wip

* wip - register

* arrange auth file structure

* add error codes

* extract filed error component form password login

* add register util function

* handle register flow - WIP

* update unsupported auth flow method reasons

* improve password input styles

* Improve UIA flow next stage calculation
complete stages can have any order so we will look for first stage which is not in completed

* process register UIA flow stages

* Extract register UIA stages component

* improve register error messages

* add focus trap & step count in UIA stages

* add reset password path and path utils

* add path with origin hook

* fix sso redirect url

* rename register token query param to token

* restyle auth screen header

* add reset password component - WIP

* add reset password form

* add netlify rewrites

* fix netlify file indentation

* test netlify redirect

* fix vite to include netlify toml

* add more netlify redirects

* add splat to public and assets path

* fix vite base name

* add option to use hash router in config and remove appVersion

* add splash screen component

* add client config loading and error screen

* fix server picker bug

* fix reset password email input type

* make auth page small screen responsive

* fix typo in reset password screen
This commit is contained in:
Ajay Bura 2024-01-21 23:50:56 +11:00 committed by GitHub
parent bb88eb7154
commit 20db27fa7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
103 changed files with 4772 additions and 543 deletions

View file

@ -0,0 +1,64 @@
import { ReactNode, useCallback, useEffect, useMemo } from 'react';
import { MatrixError, createClient } from 'matrix-js-sdk';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo';
import { promiseFulfilledResult, promiseRejectedResult } from '../utils/common';
import {
AuthFlows,
RegisterFlowStatus,
RegisterFlowsResponse,
parseRegisterErrResp,
} from '../hooks/useAuthFlows';
type AuthFlowsLoaderProps = {
fallback?: () => ReactNode;
error?: (err: unknown) => ReactNode;
children: (authFlows: AuthFlows) => ReactNode;
};
export function AuthFlowsLoader({ fallback, error, children }: AuthFlowsLoaderProps) {
const autoDiscoveryInfo = useAutoDiscoveryInfo();
const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url;
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
const [state, load] = useAsyncCallback(
useCallback(async () => {
const result = await Promise.allSettled([mx.loginFlows(), mx.registerRequest({})]);
const loginFlows = promiseFulfilledResult(result[0]);
const registerResp = promiseRejectedResult(result[1]) as MatrixError | undefined;
let registerFlows: RegisterFlowsResponse = { status: RegisterFlowStatus.InvalidRequest };
if (typeof registerResp === 'object' && registerResp.httpStatus) {
registerFlows = parseRegisterErrResp(registerResp);
}
if (!loginFlows) {
throw new Error('Missing auth flow!');
}
if ('errcode' in loginFlows) {
throw new Error('Failed to load auth flow!');
}
const authFlows: AuthFlows = {
loginFlows,
registerFlows,
};
return authFlows;
}, [mx])
);
useEffect(() => {
load();
}, [load]);
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
return fallback?.();
}
if (state.status === AsyncStatus.Error) {
return error?.(state.error);
}
return children(state.data);
}

View file

@ -0,0 +1,38 @@
import { ReactNode, useCallback, useEffect, useState } from 'react';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { ClientConfig } from '../hooks/useClientConfig';
import { trimTrailingSlash } from '../utils/common';
const getClientConfig = async (): Promise<ClientConfig> => {
const url = `${trimTrailingSlash(import.meta.env.BASE_URL)}/config.json`;
const config = await fetch(url, { method: 'GET' });
return config.json();
};
type ClientConfigLoaderProps = {
fallback?: () => ReactNode;
error?: (err: unknown, retry: () => void, ignore: () => void) => ReactNode;
children: (config: ClientConfig) => ReactNode;
};
export function ClientConfigLoader({ fallback, error, children }: ClientConfigLoaderProps) {
const [state, load] = useAsyncCallback(getClientConfig);
const [ignoreError, setIgnoreError] = useState(false);
const ignoreCallback = useCallback(() => setIgnoreError(true), []);
useEffect(() => {
load();
}, [load]);
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
return fallback?.();
}
if (!ignoreError && state.status === AsyncStatus.Error) {
return error?.(state.error, load, ignoreCallback);
}
const config: ClientConfig = state.status === AsyncStatus.Success ? state.data : {};
return children(config);
}

View file

@ -0,0 +1,35 @@
import { ReactNode, RefObject, useCallback, useRef, useState } from 'react';
import { useDebounce } from '../hooks/useDebounce';
type ConfirmPasswordMatchProps = {
initialValue: boolean;
children: (
match: boolean,
doMatch: () => void,
passRef: RefObject<HTMLInputElement>,
confPassRef: RefObject<HTMLInputElement>
) => ReactNode;
};
export function ConfirmPasswordMatch({ initialValue, children }: ConfirmPasswordMatchProps) {
const [match, setMatch] = useState(initialValue);
const passRef = useRef<HTMLInputElement>(null);
const confPassRef = useRef<HTMLInputElement>(null);
const doMatch = useDebounce(
useCallback(() => {
const pass = passRef.current?.value;
const confPass = confPassRef.current?.value;
if (!confPass) {
setMatch(initialValue);
return;
}
setMatch(pass === confPass);
}, [initialValue]),
{
wait: 500,
immediate: false,
}
);
return children(match, doMatch, passRef, confPassRef);
}

View file

@ -0,0 +1,32 @@
import { ReactNode, useCallback, useEffect } from 'react';
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
import { SpecVersions, specVersions } from '../cs-api';
import { useAutoDiscoveryInfo } from '../hooks/useAutoDiscoveryInfo';
type SpecVersionsLoaderProps = {
fallback?: () => ReactNode;
error?: (err: unknown) => ReactNode;
children: (versions: SpecVersions) => ReactNode;
};
export function SpecVersionsLoader({ fallback, error, children }: SpecVersionsLoaderProps) {
const autoDiscoveryInfo = useAutoDiscoveryInfo();
const baseUrl = autoDiscoveryInfo['m.homeserver'].base_url;
const [state, load] = useAsyncCallback(
useCallback(() => specVersions(fetch, baseUrl), [baseUrl])
);
useEffect(() => {
load();
}, [load]);
if (state.status === AsyncStatus.Idle || state.status === AsyncStatus.Loading) {
return fallback?.();
}
if (state.status === AsyncStatus.Error) {
return error?.(state.error);
}
return children(state.data);
}

View file

@ -0,0 +1,17 @@
import { ReactNode } from 'react';
import { UIAFlow } from 'matrix-js-sdk';
import { useSupportedUIAFlows } from '../hooks/useUIAFlows';
export function SupportedUIAFlowsLoader({
flows,
supportedStages,
children,
}: {
supportedStages: string[];
flows: UIAFlow[];
children: (supportedFlows: UIAFlow[]) => ReactNode;
}) {
const supportedFlows = useSupportedUIAFlows(flows, supportedStages);
return children(supportedFlows);
}

View file

@ -0,0 +1,72 @@
import React, { ReactNode } from 'react';
import {
Overlay,
OverlayBackdrop,
Box,
config,
Text,
TooltipProvider,
Tooltip,
Icons,
Icon,
Chip,
IconButton,
} from 'folds';
import FocusTrap from 'focus-trap-react';
export type UIAFlowOverlayProps = {
currentStep: number;
stepCount: number;
children: ReactNode;
onCancel: () => void;
};
export function UIAFlowOverlay({
currentStep,
stepCount,
children,
onCancel,
}: UIAFlowOverlayProps) {
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<FocusTrap focusTrapOptions={{ initialFocus: false }}>
<Box style={{ height: '100%' }} direction="Column" grow="Yes" gap="400">
<Box grow="Yes" direction="Column" alignItems="Center" justifyContent="Center">
{children}
</Box>
<Box
style={{ padding: config.space.S200 }}
shrink="No"
justifyContent="Center"
alignItems="Center"
gap="200"
>
<Chip as="div" radii="Pill" outlined>
<Text as="span" size="T300">{`Step ${currentStep}/${stepCount}`}</Text>
</Chip>
<TooltipProvider
tooltip={
<Tooltip variant="Critical">
<Text>Exit</Text>
</Tooltip>
}
position="Top"
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
variant="Critical"
size="300"
onClick={onCancel}
radii="Pill"
outlined
>
<Icon size="50" src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
</Box>
</FocusTrap>
</Overlay>
);
}

View file

@ -0,0 +1,45 @@
import React, { ComponentProps, forwardRef } from 'react';
import { Icon, IconButton, Input, config, Icons } from 'folds';
import { UseStateProvider } from '../UseStateProvider';
type PasswordInputProps = Omit<ComponentProps<typeof Input>, 'type' | 'size'> & {
size: '400' | '500';
};
export const PasswordInput = forwardRef<HTMLInputElement, PasswordInputProps>(
({ variant, size, style, after, ...props }, ref) => {
const paddingRight: string = size === '500' ? config.space.S300 : config.space.S200;
return (
<UseStateProvider initial={false}>
{(visible, setVisible) => (
<Input
{...props}
ref={ref}
style={{ paddingRight, ...style }}
type={visible ? 'text' : 'password'}
size={size}
variant={variant}
after={
<>
{after}
<IconButton
onClick={() => setVisible(!visible)}
type="button"
variant={visible ? 'Warning' : variant}
size="300"
radii="300"
>
<Icon
style={{ opacity: config.opacity.P300 }}
size="100"
src={visible ? Icons.Eye : Icons.EyeBlind}
/>
</IconButton>
</>
}
/>
)}
</UseStateProvider>
);
}
);

View file

@ -0,0 +1,12 @@
import { style } from '@vanilla-extract/css';
import { color, config } from 'folds';
export const SplashScreen = style({
minHeight: '100%',
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
});
export const SplashScreenFooter = style({
padding: config.space.S400,
});

View file

@ -0,0 +1,29 @@
import { Box, Text } from 'folds';
import React, { ReactNode } from 'react';
import classNames from 'classnames';
import * as patternsCSS from '../../styles/Patterns.css';
import * as css from './SplashScreen.css';
type SplashScreenProps = {
children: ReactNode;
};
export function SplashScreen({ children }: SplashScreenProps) {
return (
<Box
className={classNames(css.SplashScreen, patternsCSS.BackgroundDotPattern)}
direction="Column"
>
{children}
<Box
className={css.SplashScreenFooter}
shrink="No"
alignItems="Center"
justifyContent="Center"
>
<Text size="H2" align="Center">
Cinny
</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1 @@
export * from './SplashScreen';

View file

@ -0,0 +1,65 @@
import React, { useEffect, useCallback } from 'react';
import { Dialog, Text, Box, Button, config } from 'folds';
import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types';
function DummyErrorDialog({
title,
message,
onRetry,
onCancel,
}: {
title: string;
message: string;
onRetry: () => void;
onCancel: () => void;
}) {
return (
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text size="H4">{title}</Text>
<Text>{message}</Text>
</Box>
<Button variant="Critical" onClick={onRetry}>
<Text as="span" size="B400">
Retry
</Text>
</Button>
<Button variant="Critical" fill="None" outlined onClick={onCancel}>
<Text as="span" size="B400">
Cancel
</Text>
</Button>
</Box>
</Dialog>
);
}
export function AutoDummyStageDialog({ stageData, submitAuthDict, onCancel }: StageComponentProps) {
const { errorCode, error, session } = stageData;
const handleSubmit = useCallback(() => {
submitAuthDict({
type: AuthType.Dummy,
session,
});
}, [session, submitAuthDict]);
useEffect(() => {
if (!errorCode) handleSubmit();
}, [handleSubmit, errorCode]);
if (errorCode) {
return (
<DummyErrorDialog
title={errorCode}
message={error ?? 'Failed to register.'}
onRetry={handleSubmit}
onCancel={onCancel}
/>
);
}
return null;
}

View file

@ -0,0 +1,172 @@
import React, { useEffect, useCallback, FormEventHandler } from 'react';
import { Dialog, Text, Box, Button, config, Input, color, Spinner } from 'folds';
import { AuthType, MatrixError } from 'matrix-js-sdk';
import { StageComponentProps } from './types';
import { AsyncState, AsyncStatus } from '../../hooks/useAsyncCallback';
import { RequestEmailTokenCallback, RequestEmailTokenResponse } from '../../hooks/types';
function EmailErrorDialog({
title,
message,
defaultEmail,
onRetry,
onCancel,
}: {
title: string;
message: string;
defaultEmail?: string;
onRetry: (email: string) => void;
onCancel: () => void;
}) {
const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const { retryEmailInput } = evt.target as HTMLFormElement & {
retryEmailInput: HTMLInputElement;
};
const t = retryEmailInput.value;
onRetry(t);
};
return (
<Dialog>
<Box
as="form"
onSubmit={handleFormSubmit}
style={{ padding: config.space.S400 }}
direction="Column"
gap="400"
>
<Box direction="Column" gap="100">
<Text size="H4">{title}</Text>
<Text>{message}</Text>
<Text as="label" size="L400" style={{ paddingTop: config.space.S400 }}>
Email
</Text>
<Input
name="retryEmailInput"
variant="Background"
size="500"
outlined
defaultValue={defaultEmail}
required
/>
</Box>
<Button variant="Primary" type="submit">
<Text as="span" size="B400">
Send Verification Email
</Text>
</Button>
<Button variant="Critical" fill="None" outlined type="button" onClick={onCancel}>
<Text as="span" size="B400">
Cancel
</Text>
</Button>
</Box>
</Dialog>
);
}
export function EmailStageDialog({
email,
clientSecret,
stageData,
emailTokenState,
requestEmailToken,
submitAuthDict,
onCancel,
}: StageComponentProps & {
email?: string;
clientSecret: string;
emailTokenState: AsyncState<RequestEmailTokenResponse, MatrixError>;
requestEmailToken: RequestEmailTokenCallback;
}) {
const { errorCode, error, session } = stageData;
const handleSubmit = useCallback(
(sessionId: string) => {
const threepIDCreds = {
sid: sessionId,
client_secret: clientSecret,
};
submitAuthDict({
type: AuthType.Email,
threepid_creds: threepIDCreds,
threepidCreds: threepIDCreds,
session,
});
},
[submitAuthDict, session, clientSecret]
);
const handleEmailSubmit = useCallback(
(userEmail: string) => {
requestEmailToken(userEmail, clientSecret);
},
[clientSecret, requestEmailToken]
);
useEffect(() => {
if (email && !errorCode && emailTokenState.status === AsyncStatus.Idle) {
requestEmailToken(email, clientSecret);
}
}, [email, errorCode, clientSecret, emailTokenState, requestEmailToken]);
if (emailTokenState.status === AsyncStatus.Loading) {
return (
<Box direction="Column" alignItems="Center" gap="400">
<Spinner variant="Secondary" size="600" />
<Text style={{ color: color.Secondary.Main }}>Sending verification email...</Text>
</Box>
);
}
if (emailTokenState.status === AsyncStatus.Error) {
return (
<EmailErrorDialog
title={emailTokenState.error.errcode ?? 'Verify Email'}
message={
emailTokenState.error?.data?.error ??
emailTokenState.error.message ??
'Failed to send verification Email request.'
}
onRetry={handleEmailSubmit}
onCancel={onCancel}
/>
);
}
if (emailTokenState.status === AsyncStatus.Success) {
return (
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text size="H4">Verification Request Sent</Text>
<Text>{`Please check your email "${emailTokenState.data.email}" and validate before continuing further.`}</Text>
{errorCode && (
<Text style={{ color: color.Critical.Main }}>{`${errorCode}: ${error}`}</Text>
)}
</Box>
<Button variant="Primary" onClick={() => handleSubmit(emailTokenState.data.result.sid)}>
<Text as="span" size="B400">
Continue
</Text>
</Button>
</Box>
</Dialog>
);
}
if (!email) {
return (
<EmailErrorDialog
title="Provide Email"
message="Please provide email to send verification request."
onRetry={handleEmailSubmit}
onCancel={onCancel}
/>
);
}
return null;
}

View file

@ -0,0 +1,64 @@
import React from 'react';
import { Dialog, Text, Box, Button, config } from 'folds';
import { AuthType } from 'matrix-js-sdk';
import ReCAPTCHA from 'react-google-recaptcha';
import { StageComponentProps } from './types';
function ReCaptchaErrorDialog({
title,
message,
onCancel,
}: {
title: string;
message: string;
onCancel: () => void;
}) {
return (
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text size="H4">{title}</Text>
<Text>{message}</Text>
</Box>
<Button variant="Critical" fill="None" outlined onClick={onCancel}>
<Text as="span" size="B400">
Cancel
</Text>
</Button>
</Box>
</Dialog>
);
}
export function ReCaptchaStageDialog({ stageData, submitAuthDict, onCancel }: StageComponentProps) {
const { info, session } = stageData;
const publicKey = info?.public_key;
const handleChange = (token: string | null) => {
submitAuthDict({
type: AuthType.Recaptcha,
response: token,
session,
});
};
if (typeof publicKey !== 'string' || !session) {
return (
<ReCaptchaErrorDialog
title="Invalid Data"
message="No valid data found to proceed with ReCAPTCHA."
onCancel={onCancel}
/>
);
}
return (
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text>Please check the box below to proceed.</Text>
<ReCAPTCHA sitekey={publicKey} onChange={handleChange} />
</Box>
</Dialog>
);
}

View file

@ -0,0 +1,117 @@
import React, { useEffect, useCallback, FormEventHandler } from 'react';
import { Dialog, Text, Box, Button, config, Input } from 'folds';
import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types';
function RegistrationTokenErrorDialog({
title,
message,
defaultToken,
onRetry,
onCancel,
}: {
title: string;
message: string;
defaultToken?: string;
onRetry: (token: string) => void;
onCancel: () => void;
}) {
const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const { retryTokenInput } = evt.target as HTMLFormElement & {
retryTokenInput: HTMLInputElement;
};
const t = retryTokenInput.value;
onRetry(t);
};
return (
<Dialog>
<Box
as="form"
onSubmit={handleFormSubmit}
style={{ padding: config.space.S400 }}
direction="Column"
gap="400"
>
<Box direction="Column" gap="100">
<Text size="H4">{title}</Text>
<Text>{message}</Text>
<Text as="label" size="L400" style={{ paddingTop: config.space.S400 }}>
Registration Token
</Text>
<Input
name="retryTokenInput"
variant="Background"
size="500"
outlined
defaultValue={defaultToken}
required
/>
</Box>
<Button variant="Critical" type="submit">
<Text as="span" size="B400">
Retry
</Text>
</Button>
<Button variant="Critical" fill="None" outlined type="button" onClick={onCancel}>
<Text as="span" size="B400">
Cancel
</Text>
</Button>
</Box>
</Dialog>
);
}
export function RegistrationTokenStageDialog({
token,
stageData,
submitAuthDict,
onCancel,
}: StageComponentProps & {
token?: string;
}) {
const { errorCode, error, session } = stageData;
const handleSubmit = useCallback(
(t: string) => {
submitAuthDict({
type: AuthType.RegistrationToken,
token: t,
session,
});
},
[session, submitAuthDict]
);
useEffect(() => {
if (token && !errorCode) handleSubmit(token);
}, [handleSubmit, token, errorCode]);
if (errorCode) {
return (
<RegistrationTokenErrorDialog
defaultToken={token}
title={errorCode}
message={error ?? 'Invalid registration token provided.'}
onRetry={handleSubmit}
onCancel={onCancel}
/>
);
}
if (!token) {
return (
<RegistrationTokenErrorDialog
defaultToken={token}
title="Registration Token"
message="Please submit registration token provided by you homeserver admin."
onRetry={handleSubmit}
onCancel={onCancel}
/>
);
}
return null;
}

View file

@ -0,0 +1,69 @@
import React, { useEffect, useCallback } from 'react';
import { Dialog, Text, Box, Button, config } from 'folds';
import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types';
function TermsErrorDialog({
title,
message,
onRetry,
onCancel,
}: {
title: string;
message: string;
onRetry: () => void;
onCancel: () => void;
}) {
return (
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text size="H4">{title}</Text>
<Text>{message}</Text>
</Box>
<Button variant="Critical" onClick={onRetry}>
<Text as="span" size="B400">
Retry
</Text>
</Button>
<Button variant="Critical" fill="None" outlined onClick={onCancel}>
<Text as="span" size="B400">
Cancel
</Text>
</Button>
</Box>
</Dialog>
);
}
export function AutoTermsStageDialog({ stageData, submitAuthDict, onCancel }: StageComponentProps) {
const { errorCode, error, session } = stageData;
const handleSubmit = useCallback(
() =>
submitAuthDict({
type: AuthType.Terms,
session,
}),
[session, submitAuthDict]
);
useEffect(() => {
if (!errorCode) {
handleSubmit();
}
}, [session, errorCode, handleSubmit]);
if (errorCode) {
return (
<TermsErrorDialog
title={errorCode}
message={error ?? 'Failed to submit Terms and Condition Acceptance.'}
onRetry={handleSubmit}
onCancel={onCancel}
/>
);
}
return null;
}

View file

@ -0,0 +1,6 @@
export * from './types';
export * from './DummyStage';
export * from './EmailStage';
export * from './ReCaptchaStage';
export * from './RegistrationTokenStage';
export * from './TermsStage';

View file

@ -0,0 +1,8 @@
import { AuthDict } from 'matrix-js-sdk';
import { AuthStageData } from '../../hooks/useUIAFlows';
export type StageComponentProps = {
stageData: AuthStageData;
submitAuthDict: (authDict: AuthDict) => void;
onCancel: () => void;
};