mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-13 10:40:28 +03:00
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:
parent
bb88eb7154
commit
20db27fa7e
103 changed files with 4772 additions and 543 deletions
64
src/app/components/AuthFlowsLoader.tsx
Normal file
64
src/app/components/AuthFlowsLoader.tsx
Normal 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);
|
||||
}
|
||||
38
src/app/components/ClientConfigLoader.tsx
Normal file
38
src/app/components/ClientConfigLoader.tsx
Normal 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);
|
||||
}
|
||||
35
src/app/components/ConfirmPasswordMatch.tsx
Normal file
35
src/app/components/ConfirmPasswordMatch.tsx
Normal 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);
|
||||
}
|
||||
32
src/app/components/SpecVersionsLoader.tsx
Normal file
32
src/app/components/SpecVersionsLoader.tsx
Normal 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);
|
||||
}
|
||||
17
src/app/components/SupportedUIAFlowsLoader.tsx
Normal file
17
src/app/components/SupportedUIAFlowsLoader.tsx
Normal 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);
|
||||
}
|
||||
72
src/app/components/UIAFlowOverlay.tsx
Normal file
72
src/app/components/UIAFlowOverlay.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/app/components/password-input/PasswordInput.tsx
Normal file
45
src/app/components/password-input/PasswordInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
12
src/app/components/splash-screen/SplashScreen.css.ts
Normal file
12
src/app/components/splash-screen/SplashScreen.css.ts
Normal 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,
|
||||
});
|
||||
29
src/app/components/splash-screen/SplashScreen.tsx
Normal file
29
src/app/components/splash-screen/SplashScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/components/splash-screen/index.ts
Normal file
1
src/app/components/splash-screen/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './SplashScreen';
|
||||
65
src/app/components/uia-stages/DummyStage.tsx
Normal file
65
src/app/components/uia-stages/DummyStage.tsx
Normal 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;
|
||||
}
|
||||
172
src/app/components/uia-stages/EmailStage.tsx
Normal file
172
src/app/components/uia-stages/EmailStage.tsx
Normal 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;
|
||||
}
|
||||
64
src/app/components/uia-stages/ReCaptchaStage.tsx
Normal file
64
src/app/components/uia-stages/ReCaptchaStage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
117
src/app/components/uia-stages/RegistrationTokenStage.tsx
Normal file
117
src/app/components/uia-stages/RegistrationTokenStage.tsx
Normal 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;
|
||||
}
|
||||
69
src/app/components/uia-stages/TermsStage.tsx
Normal file
69
src/app/components/uia-stages/TermsStage.tsx
Normal 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;
|
||||
}
|
||||
6
src/app/components/uia-stages/index.ts
Normal file
6
src/app/components/uia-stages/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export * from './types';
|
||||
export * from './DummyStage';
|
||||
export * from './EmailStage';
|
||||
export * from './ReCaptchaStage';
|
||||
export * from './RegistrationTokenStage';
|
||||
export * from './TermsStage';
|
||||
8
src/app/components/uia-stages/types.ts
Normal file
8
src/app/components/uia-stages/types.ts
Normal 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;
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue