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 4775 additions and 546 deletions
|
|
@ -1,3 +0,0 @@
|
||||||
# Redirects from what the browser requests to what we serve
|
|
||||||
/login /
|
|
||||||
/register /
|
|
||||||
3
build.config.ts
Normal file
3
build.config.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export default {
|
||||||
|
base: '/',
|
||||||
|
};
|
||||||
|
|
@ -8,5 +8,10 @@
|
||||||
"mozilla.org",
|
"mozilla.org",
|
||||||
"xmr.se"
|
"xmr.se"
|
||||||
],
|
],
|
||||||
"allowCustomHomeservers": true
|
"allowCustomHomeservers": true,
|
||||||
|
|
||||||
|
"hashRouter": {
|
||||||
|
"enabled": false,
|
||||||
|
"basename": "/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,6 @@
|
||||||
<audio id="inviteSound">
|
<audio id="inviteSound">
|
||||||
<source src="./public/sound/invite.ogg" type="audio/ogg" />
|
<source src="./public/sound/invite.ogg" type="audio/ogg" />
|
||||||
</audio>
|
</audio>
|
||||||
<script type="module" src="./src/index.jsx"></script>
|
<script type="module" src="./src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
34
netlify.toml
Normal file
34
netlify.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
[[redirects]]
|
||||||
|
from = "/config.json"
|
||||||
|
to = "/config.json"
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/manifest.json"
|
||||||
|
to = "/manifest.json"
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/olm.wasm"
|
||||||
|
to = "/olm.wasm"
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/pdf.worker.min.js"
|
||||||
|
to = "/pdf.worker.min.js"
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/public/*"
|
||||||
|
to = "/public/:splat"
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/assets/*"
|
||||||
|
to = "/assets/:splat"
|
||||||
|
status = 200
|
||||||
|
|
||||||
|
[[redirects]]
|
||||||
|
from = "/*"
|
||||||
|
to = "/index.html"
|
||||||
|
status = 200
|
||||||
1018
package-lock.json
generated
1018
package-lock.json
generated
File diff suppressed because it is too large
Load diff
22
package.json
22
package.json
|
|
@ -39,13 +39,13 @@
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
"flux": "4.0.3",
|
"flux": "4.0.3",
|
||||||
"focus-trap-react": "10.0.2",
|
"focus-trap-react": "10.0.2",
|
||||||
"folds": "1.5.0",
|
"folds": "1.5.1",
|
||||||
"formik": "2.2.9",
|
"formik": "2.2.9",
|
||||||
"html-dom-parser": "4.0.0",
|
"html-dom-parser": "4.0.0",
|
||||||
"html-react-parser": "4.2.0",
|
"html-react-parser": "4.2.0",
|
||||||
"immer": "9.0.16",
|
"immer": "9.0.16",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
"jotai": "1.12.0",
|
"jotai": "2.6.0",
|
||||||
"katex": "0.16.4",
|
"katex": "0.16.4",
|
||||||
"linkify-html": "4.0.2",
|
"linkify-html": "4.0.2",
|
||||||
"linkify-react": "4.1.1",
|
"linkify-react": "4.1.1",
|
||||||
|
|
@ -55,17 +55,18 @@
|
||||||
"pdfjs-dist": "3.10.111",
|
"pdfjs-dist": "3.10.111",
|
||||||
"prismjs": "1.29.0",
|
"prismjs": "1.29.0",
|
||||||
"prop-types": "15.8.1",
|
"prop-types": "15.8.1",
|
||||||
"react": "17.0.2",
|
"react": "18.2.0",
|
||||||
"react-aria": "3.29.1",
|
"react-aria": "3.29.1",
|
||||||
"react-autosize-textarea": "7.1.0",
|
"react-autosize-textarea": "7.1.0",
|
||||||
"react-blurhash": "0.2.0",
|
"react-blurhash": "0.2.0",
|
||||||
"react-dnd": "15.1.2",
|
"react-dnd": "16.0.1",
|
||||||
"react-dnd-html5-backend": "15.1.3",
|
"react-dnd-html5-backend": "16.0.1",
|
||||||
"react-dom": "17.0.2",
|
"react-dom": "18.2.0",
|
||||||
"react-error-boundary": "4.0.10",
|
"react-error-boundary": "4.0.10",
|
||||||
"react-google-recaptcha": "2.1.0",
|
"react-google-recaptcha": "2.1.0",
|
||||||
"react-modal": "3.16.1",
|
"react-modal": "3.16.1",
|
||||||
"react-range": "1.8.14",
|
"react-range": "1.8.14",
|
||||||
|
"react-router-dom": "6.20.0",
|
||||||
"sanitize-html": "2.8.0",
|
"sanitize-html": "2.8.0",
|
||||||
"slate": "0.94.1",
|
"slate": "0.94.1",
|
||||||
"slate-history": "0.93.0",
|
"slate-history": "0.93.0",
|
||||||
|
|
@ -81,13 +82,14 @@
|
||||||
"@types/file-saver": "2.0.5",
|
"@types/file-saver": "2.0.5",
|
||||||
"@types/node": "18.11.18",
|
"@types/node": "18.11.18",
|
||||||
"@types/prismjs": "1.26.0",
|
"@types/prismjs": "1.26.0",
|
||||||
"@types/react": "18.0.26",
|
"@types/react": "18.2.39",
|
||||||
"@types/react-dom": "18.0.9",
|
"@types/react-dom": "18.2.17",
|
||||||
|
"@types/react-google-recaptcha": "2.1.8",
|
||||||
"@types/sanitize-html": "2.9.0",
|
"@types/sanitize-html": "2.9.0",
|
||||||
"@types/ua-parser-js": "0.7.36",
|
"@types/ua-parser-js": "0.7.36",
|
||||||
"@typescript-eslint/eslint-plugin": "5.46.1",
|
"@typescript-eslint/eslint-plugin": "5.46.1",
|
||||||
"@typescript-eslint/parser": "5.46.1",
|
"@typescript-eslint/parser": "5.46.1",
|
||||||
"@vitejs/plugin-react": "3.0.0",
|
"@vitejs/plugin-react": "4.2.0",
|
||||||
"buffer": "6.0.3",
|
"buffer": "6.0.3",
|
||||||
"eslint": "8.29.0",
|
"eslint": "8.29.0",
|
||||||
"eslint-config-airbnb": "19.0.4",
|
"eslint-config-airbnb": "19.0.4",
|
||||||
|
|
@ -100,7 +102,7 @@
|
||||||
"prettier": "2.8.1",
|
"prettier": "2.8.1",
|
||||||
"sass": "1.56.2",
|
"sass": "1.56.2",
|
||||||
"typescript": "4.9.4",
|
"typescript": "4.9.4",
|
||||||
"vite": "4.3.9",
|
"vite": "5.0.8",
|
||||||
"vite-plugin-static-copy": "0.13.0"
|
"vite-plugin-static-copy": "0.13.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue