1
0
Fork 0
forked from mirror/cinny

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

@ -1,3 +0,0 @@
# Redirects from what the browser requests to what we serve
/login /
/register /

3
build.config.ts Normal file
View file

@ -0,0 +1,3 @@
export default {
base: '/',
};

View file

@ -8,5 +8,10 @@
"mozilla.org",
"xmr.se"
],
"allowCustomHomeservers": true
"allowCustomHomeservers": true,
"hashRouter": {
"enabled": false,
"basename": "/"
}
}

View file

@ -96,6 +96,6 @@
<audio id="inviteSound">
<source src="./public/sound/invite.ogg" type="audio/ogg" />
</audio>
<script type="module" src="./src/index.jsx"></script>
<script type="module" src="./src/index.tsx"></script>
</body>
</html>

34
netlify.toml Normal file
View 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

File diff suppressed because it is too large Load diff

View file

@ -39,13 +39,13 @@
"file-saver": "2.0.5",
"flux": "4.0.3",
"focus-trap-react": "10.0.2",
"folds": "1.5.0",
"folds": "1.5.1",
"formik": "2.2.9",
"html-dom-parser": "4.0.0",
"html-react-parser": "4.2.0",
"immer": "9.0.16",
"is-hotkey": "0.2.0",
"jotai": "1.12.0",
"jotai": "2.6.0",
"katex": "0.16.4",
"linkify-html": "4.0.2",
"linkify-react": "4.1.1",
@ -55,17 +55,18 @@
"pdfjs-dist": "3.10.111",
"prismjs": "1.29.0",
"prop-types": "15.8.1",
"react": "17.0.2",
"react": "18.2.0",
"react-aria": "3.29.1",
"react-autosize-textarea": "7.1.0",
"react-blurhash": "0.2.0",
"react-dnd": "15.1.2",
"react-dnd-html5-backend": "15.1.3",
"react-dom": "17.0.2",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "18.2.0",
"react-error-boundary": "4.0.10",
"react-google-recaptcha": "2.1.0",
"react-modal": "3.16.1",
"react-range": "1.8.14",
"react-router-dom": "6.20.0",
"sanitize-html": "2.8.0",
"slate": "0.94.1",
"slate-history": "0.93.0",
@ -81,13 +82,14 @@
"@types/file-saver": "2.0.5",
"@types/node": "18.11.18",
"@types/prismjs": "1.26.0",
"@types/react": "18.0.26",
"@types/react-dom": "18.0.9",
"@types/react": "18.2.39",
"@types/react-dom": "18.2.17",
"@types/react-google-recaptcha": "2.1.8",
"@types/sanitize-html": "2.9.0",
"@types/ua-parser-js": "0.7.36",
"@typescript-eslint/eslint-plugin": "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",
"eslint": "8.29.0",
"eslint-config-airbnb": "19.0.4",
@ -100,7 +102,7 @@
"prettier": "2.8.1",
"sass": "1.56.2",
"typescript": "4.9.4",
"vite": "4.3.9",
"vite": "5.0.8",
"vite-plugin-static-copy": "0.13.0"
}
}

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;
};

115
src/app/cs-api.ts Normal file
View file

@ -0,0 +1,115 @@
import to from 'await-to-js';
import { trimTrailingSlash } from './utils/common';
export enum AutoDiscoveryAction {
PROMPT = 'PROMPT',
IGNORE = 'IGNORE',
FAIL_PROMPT = 'FAIL_PROMPT',
FAIL_ERROR = 'FAIL_ERROR',
}
export type AutoDiscoveryError = {
host: string;
action: AutoDiscoveryAction;
};
export type AutoDiscoveryInfo = Record<string, unknown> & {
'm.homeserver': {
base_url: string;
};
'm.identity_server'?: {
base_url: string;
};
};
export const autoDiscovery = async (
request: typeof fetch,
server: string
): Promise<[AutoDiscoveryError, undefined] | [undefined, AutoDiscoveryInfo]> => {
const host = /^https?:\/\//.test(server) ? trimTrailingSlash(server) : `https://${server}`;
const autoDiscoveryUrl = `${host}/.well-known/matrix/client`;
const [err, response] = await to(request(autoDiscoveryUrl, { method: 'GET' }));
if (err || response.status === 404) {
// AutoDiscoveryAction.IGNORE
// We will use default value for IGNORE action
return [
undefined,
{
'm.homeserver': {
base_url: host,
},
},
];
}
if (response.status !== 200) {
return [
{
host,
action: AutoDiscoveryAction.FAIL_PROMPT,
},
undefined,
];
}
const [contentErr, content] = await to<AutoDiscoveryInfo>(response.json());
if (contentErr || typeof content !== 'object') {
return [
{
host,
action: AutoDiscoveryAction.FAIL_PROMPT,
},
undefined,
];
}
const baseUrl = content['m.homeserver']?.base_url;
if (typeof baseUrl !== 'string') {
return [
{
host,
action: AutoDiscoveryAction.FAIL_PROMPT,
},
undefined,
];
}
if (/^https?:\/\//.test(baseUrl) === false) {
return [
{
host,
action: AutoDiscoveryAction.FAIL_ERROR,
},
undefined,
];
}
content['m.homeserver'].base_url = trimTrailingSlash(baseUrl);
if (content['m.identity_server']) {
content['m.identity_server'].base_url = trimTrailingSlash(
content['m.identity_server'].base_url
);
}
return [undefined, content];
};
export type SpecVersions = {
versions: string[];
unstable_features?: Record<string, boolean>;
};
export const specVersions = async (
request: typeof fetch,
baseUrl: string
): Promise<SpecVersions> => {
const res = await request(`${baseUrl}/_matrix/client/versions`);
const data = (await res.json()) as unknown;
if (data && typeof data === 'object' && 'versions' in data && Array.isArray(data.versions)) {
return data as SpecVersions;
}
throw new Error('Homeserver URL does not appear to be a valid Matrix homeserver');
};

37
src/app/cs-errorcode.ts Normal file
View file

@ -0,0 +1,37 @@
export enum ErrorCode {
M_FORBIDDEN = 'M_FORBIDDEN',
M_UNKNOWN_TOKEN = 'M_UNKNOWN_TOKEN',
M_MISSING_TOKEN = 'M_MISSING_TOKEN',
M_BAD_JSON = 'M_BAD_JSON',
M_NOT_JSON = 'M_NOT_JSON',
M_NOT_FOUND = 'M_NOT_FOUND',
M_LIMIT_EXCEEDED = 'M_LIMIT_EXCEEDED',
M_UNRECOGNIZED = 'M_UNRECOGNIZED',
M_UNKNOWN = 'M_UNKNOWN',
M_UNAUTHORIZED = 'M_UNAUTHORIZED',
M_USER_DEACTIVATED = 'M_USER_DEACTIVATED',
M_USER_IN_USE = 'M_USER_IN_USE',
M_INVALID_USERNAME = 'M_INVALID_USERNAME',
M_WEAK_PASSWORD = 'M_WEAK_PASSWORD',
M_PASSWORD_TOO_SHORT = 'M_PASSWORD_TOO_SHORT',
M_ROOM_IN_USE = 'M_ROOM_IN_USE',
M_INVALID_ROOM_STATE = 'M_INVALID_ROOM_STATE',
M_THREEPID_IN_USE = 'M_THREEPID_IN_USE',
M_THREEPID_NOT_FOUND = 'M_THREEPID_NOT_FOUND',
M_THREEPID_AUTH_FAILED = 'M_THREEPID_AUTH_FAILED',
M_THREEPID_DENIED = 'M_THREEPID_DENIED',
M_SERVER_NOT_TRUSTED = 'M_SERVER_NOT_TRUSTED',
M_UNSUPPORTED_ROOM_VERSION = 'M_UNSUPPORTED_ROOM_VERSION',
M_INCOMPATIBLE_ROOM_VERSION = 'M_INCOMPATIBLE_ROOM_VERSION',
M_BAD_STATE = 'M_BAD_STATE',
M_GUEST_ACCESS_FORBIDDEN = 'M_GUEST_ACCESS_FORBIDDEN',
M_CAPTCHA_NEEDED = 'M_CAPTCHA_NEEDED',
M_CAPTCHA_INVALID = 'M_CAPTCHA_INVALID',
M_MISSING_PARAM = 'M_MISSING_PARAM',
M_INVALID_PARAM = 'M_INVALID_PARAM',
M_TOO_LARGE = 'M_TOO_LARGE',
M_EXCLUSIVE = 'M_EXCLUSIVE',
M_RESOURCE_LIMIT_EXCEEDED = 'M_RESOURCE_LIMIT_EXCEEDED',
M_CANNOT_LEAVE_SERVER_NOTICE_ROOM = 'M_CANNOT_LEAVE_SERVER_NOTICE_ROOM',
}

12
src/app/hooks/types.ts Normal file
View file

@ -0,0 +1,12 @@
import { IRequestTokenResponse } from 'matrix-js-sdk';
export type RequestEmailTokenResponse = {
email: string;
clientSecret: string;
result: IRequestTokenResponse;
};
export type RequestEmailTokenCallback = (
email: string,
clientSecret: string,
nextLink?: string
) => Promise<RequestEmailTokenResponse>;

View file

@ -1,4 +1,5 @@
import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { useAlive } from './useAlive';
export enum AsyncStatus {
@ -16,36 +17,56 @@ export type AsyncLoading = {
status: AsyncStatus.Loading;
};
export type AsyncSuccess<T> = {
export type AsyncSuccess<D> = {
status: AsyncStatus.Success;
data: T;
data: D;
};
export type AsyncError = {
export type AsyncError<E = unknown> = {
status: AsyncStatus.Error;
error: unknown;
error: E;
};
export type AsyncState<T> = AsyncIdle | AsyncLoading | AsyncSuccess<T> | AsyncError;
export type AsyncState<D, E = unknown> = AsyncIdle | AsyncLoading | AsyncSuccess<D> | AsyncError<E>;
export type AsyncCallback<TArgs extends unknown[], TData> = (...args: TArgs) => Promise<TData>;
export const useAsyncCallback = <TArgs extends unknown[], TData>(
export const useAsyncCallback = <TData, TError, TArgs extends unknown[]>(
asyncCallback: AsyncCallback<TArgs, TData>
): [AsyncState<TData>, AsyncCallback<TArgs, TData>] => {
const [state, setState] = useState<AsyncState<TData>>({
): [AsyncState<TData, TError>, AsyncCallback<TArgs, TData>] => {
const [state, setState] = useState<AsyncState<TData, TError>>({
status: AsyncStatus.Idle,
});
const alive = useAlive();
// Tracks the request number.
// If two or more requests are made subsequently
// we will throw all old request's response after they resolved.
const reqNumberRef = useRef(0);
const callback: AsyncCallback<TArgs, TData> = useCallback(
async (...args) => {
setState({
status: AsyncStatus.Loading,
queueMicrotask(() => {
// Warning: flushSync was called from inside a lifecycle method.
// React cannot flush when React is already rendering.
// Consider moving this call to a scheduler task or micro task.
flushSync(() => {
// flushSync because
// https://github.com/facebook/react/issues/26713#issuecomment-1872085134
setState({
status: AsyncStatus.Loading,
});
});
});
reqNumberRef.current += 1;
const currentReqNumber = reqNumberRef.current;
try {
const data = await asyncCallback(...args);
if (currentReqNumber !== reqNumberRef.current) {
throw new Error('AsyncCallbackHook: Request replaced!');
}
if (alive()) {
setState({
status: AsyncStatus.Success,
@ -54,10 +75,13 @@ export const useAsyncCallback = <TArgs extends unknown[], TData>(
}
return data;
} catch (e) {
if (currentReqNumber !== reqNumberRef.current) {
throw new Error('AsyncCallbackHook: Request replaced!');
}
if (alive()) {
setState({
status: AsyncStatus.Error,
error: e,
error: e as TError,
});
}
throw e;

View file

@ -0,0 +1,59 @@
import { createContext, useContext } from 'react';
import { IAuthData, MatrixError } from 'matrix-js-sdk';
import { ILoginFlowsResponse } from 'matrix-js-sdk/lib/@types/auth';
export enum RegisterFlowStatus {
FlowRequired = 401,
InvalidRequest = 400,
RegistrationDisabled = 403,
RateLimited = 429,
}
export type RegisterFlowsResponse =
| {
status: RegisterFlowStatus.FlowRequired;
data: IAuthData;
}
| {
status: Exclude<RegisterFlowStatus, RegisterFlowStatus.FlowRequired>;
};
export const parseRegisterErrResp = (matrixError: MatrixError): RegisterFlowsResponse => {
switch (matrixError.httpStatus) {
case RegisterFlowStatus.InvalidRequest: {
return { status: RegisterFlowStatus.InvalidRequest };
}
case RegisterFlowStatus.RateLimited: {
return { status: RegisterFlowStatus.RateLimited };
}
case RegisterFlowStatus.RegistrationDisabled: {
return { status: RegisterFlowStatus.RegistrationDisabled };
}
case RegisterFlowStatus.FlowRequired: {
return {
status: RegisterFlowStatus.FlowRequired,
data: matrixError.data as IAuthData,
};
}
default: {
return { status: RegisterFlowStatus.InvalidRequest };
}
}
};
export type AuthFlows = {
loginFlows: ILoginFlowsResponse;
registerFlows: RegisterFlowsResponse;
};
const AuthFlowsContext = createContext<AuthFlows | null>(null);
export const AuthFlowsProvider = AuthFlowsContext.Provider;
export const useAuthFlows = (): AuthFlows => {
const authFlows = useContext(AuthFlowsContext);
if (!authFlows) {
throw new Error('Auth Flow info is not loaded!');
}
return authFlows;
};

View file

@ -0,0 +1,14 @@
import { createContext, useContext } from 'react';
const AuthServerContext = createContext<string | null>(null);
export const AuthServerProvider = AuthServerContext.Provider;
export const useAuthServer = (): string => {
const server = useContext(AuthServerContext);
if (server === null) {
throw new Error('Auth server is not provided!');
}
return server;
};

View file

@ -0,0 +1,15 @@
import { createContext, useContext } from 'react';
import { AutoDiscoveryInfo } from '../cs-api';
const AutoDiscoverInfoContext = createContext<AutoDiscoveryInfo | null>(null);
export const AutoDiscoveryInfoProvider = AutoDiscoverInfoContext.Provider;
export const useAutoDiscoveryInfo = (): AutoDiscoveryInfo => {
const autoDiscoveryInfo = useContext(AutoDiscoverInfoContext);
if (!autoDiscoveryInfo) {
throw new Error('Auto Discovery Info not loaded');
}
return autoDiscoveryInfo;
};

View file

@ -0,0 +1,33 @@
import { createContext, useContext } from 'react';
export type ClientConfig = {
defaultHomeserver?: number;
homeserverList?: string[];
allowCustomHomeservers?: boolean;
hashRouter?: {
enabled?: boolean;
basename?: string;
};
};
const ClientConfigContext = createContext<ClientConfig | null>(null);
export const ClientConfigProvider = ClientConfigContext.Provider;
export function useClientConfig(): ClientConfig {
const config = useContext(ClientConfigContext);
if (!config) throw new Error('Client config are not provided!');
return config;
}
export const clientDefaultServer = (clientConfig: ClientConfig): string =>
clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org';
export const clientAllowedServer = (clientConfig: ClientConfig, server: string): boolean => {
const { homeserverList, allowCustomHomeservers } = clientConfig;
if (allowCustomHomeservers) return true;
return homeserverList?.includes(server) === true;
};

View file

@ -9,7 +9,7 @@ export function useCrossSigningStatus() {
const [isCSEnabled, setIsCSEnabled] = useState(hasCrossSigningAccountData());
useEffect(() => {
if (isCSEnabled) return null;
if (isCSEnabled) return undefined;
const handleAccountData = (event) => {
if (event.getType() === 'm.cross_signing.master') {
setIsCSEnabled(true);

View file

@ -0,0 +1,38 @@
import { useMemo } from 'react';
import { ILoginFlow, IPasswordFlow, ISSOFlow, LoginFlow } from 'matrix-js-sdk/lib/@types/auth';
import { WithRequiredProp } from '../../types/utils';
export type Required_SSOFlow = WithRequiredProp<ISSOFlow, 'identity_providers'>;
export const getSSOFlow = (loginFlows: LoginFlow[]): Required_SSOFlow | undefined =>
loginFlows.find(
(flow) =>
(flow.type === 'm.login.sso' || flow.type === 'm.login.cas') &&
'identity_providers' in flow &&
Array.isArray(flow.identity_providers) &&
flow.identity_providers.length > 0
) as Required_SSOFlow | undefined;
export const getPasswordFlow = (loginFlows: LoginFlow[]): IPasswordFlow | undefined =>
loginFlows.find((flow) => flow.type === 'm.login.password') as IPasswordFlow;
export const getTokenFlow = (loginFlows: LoginFlow[]): LoginFlow | undefined =>
loginFlows.find((flow) => flow.type === 'm.login.token') as ILoginFlow & {
type: 'm.login.token';
};
export type ParsedLoginFlows = {
password?: LoginFlow;
token?: LoginFlow;
sso?: Required_SSOFlow;
};
export const useParsedLoginFlows = (loginFlows: LoginFlow[]) => {
const parsedFlow: ParsedLoginFlows = useMemo<ParsedLoginFlows>(
() => ({
password: getPasswordFlow(loginFlows),
token: getTokenFlow(loginFlows),
sso: getSSOFlow(loginFlows),
}),
[loginFlows]
);
return parsedFlow;
};

View file

@ -0,0 +1,32 @@
import { MatrixClient, MatrixError } from 'matrix-js-sdk';
import { useCallback, useRef } from 'react';
import { AsyncState, useAsyncCallback } from './useAsyncCallback';
import { RequestEmailTokenCallback, RequestEmailTokenResponse } from './types';
export const usePasswordEmail = (
mx: MatrixClient
): [AsyncState<RequestEmailTokenResponse, MatrixError>, RequestEmailTokenCallback] => {
const sendAttemptRef = useRef(1);
const passwordEmailCallback: RequestEmailTokenCallback = useCallback(
async (email, clientSecret, nextLink) => {
const sendAttempt = sendAttemptRef.current;
sendAttemptRef.current += 1;
const result = await mx.requestPasswordEmailToken(email, clientSecret, sendAttempt, nextLink);
return {
email,
clientSecret,
result,
};
},
[mx]
);
const [passwordEmailState, passwordEmail] = useAsyncCallback<
RequestEmailTokenResponse,
MatrixError,
Parameters<RequestEmailTokenCallback>
>(passwordEmailCallback);
return [passwordEmailState, passwordEmail];
};

View file

@ -0,0 +1,26 @@
import { useMemo } from 'react';
import { useClientConfig } from './useClientConfig';
import { trimLeadingSlash, trimSlash, trimTrailingSlash } from '../utils/common';
export const usePathWithOrigin = (path: string): string => {
const { hashRouter } = useClientConfig();
const { origin } = window.location;
const pathWithOrigin = useMemo(() => {
let url: string = trimSlash(origin);
url += `/${trimSlash(import.meta.env.BASE_URL ?? '')}`;
url = trimTrailingSlash(url);
if (hashRouter?.enabled) {
url += `/#/${trimSlash(hashRouter.basename ?? '')}`;
url = trimTrailingSlash(url);
}
url += `/${trimLeadingSlash(path)}`;
return url;
}, [path, hashRouter, origin]);
return pathWithOrigin;
};

View file

@ -0,0 +1,32 @@
import { MatrixClient, MatrixError } from 'matrix-js-sdk';
import { useCallback, useRef } from 'react';
import { AsyncState, useAsyncCallback } from './useAsyncCallback';
import { RequestEmailTokenCallback, RequestEmailTokenResponse } from './types';
export const useRegisterEmail = (
mx: MatrixClient
): [AsyncState<RequestEmailTokenResponse, MatrixError>, RequestEmailTokenCallback] => {
const sendAttemptRef = useRef(1);
const registerEmailCallback: RequestEmailTokenCallback = useCallback(
async (email, clientSecret, nextLink) => {
const sendAttempt = sendAttemptRef.current;
sendAttemptRef.current += 1;
const result = await mx.requestRegisterEmailToken(email, clientSecret, sendAttempt, nextLink);
return {
email,
clientSecret,
result,
};
},
[mx]
);
const [registerEmailState, registerEmail] = useAsyncCallback<
RequestEmailTokenResponse,
MatrixError,
Parameters<RequestEmailTokenCallback>
>(registerEmailCallback);
return [registerEmailState, registerEmail];
};

View file

@ -0,0 +1,12 @@
import { createContext, useContext } from 'react';
import { SpecVersions } from '../cs-api';
const SpecVersionsContext = createContext<SpecVersions | null>(null);
export const SpecVersionsProvider = SpecVersionsContext.Provider;
export function useSpecVersions(): SpecVersions {
const versions = useContext(SpecVersionsContext);
if (!versions) throw new Error('Server versions are not provided!');
return versions;
}

View file

@ -0,0 +1,96 @@
import { AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
import { useCallback, useMemo } from 'react';
import {
getSupportedUIAFlows,
getUIACompleted,
getUIAError,
getUIAErrorCode,
getUIAParams,
getUIASession,
} from '../utils/matrix-uia';
export const SUPPORTED_FLOW_TYPES = [
AuthType.Dummy,
AuthType.Password,
AuthType.Email,
AuthType.Terms,
AuthType.Recaptcha,
AuthType.RegistrationToken,
] as const;
export const useSupportedUIAFlows = (uiaFlows: UIAFlow[], supportedStages: string[]): UIAFlow[] =>
useMemo(() => getSupportedUIAFlows(uiaFlows, supportedStages), [uiaFlows, supportedStages]);
export const useUIACompleted = (authData: IAuthData): string[] =>
useMemo(() => getUIACompleted(authData), [authData]);
export const useUIAParams = (authData: IAuthData) =>
useMemo(() => getUIAParams(authData), [authData]);
export const useUIASession = (authData: IAuthData) =>
useMemo(() => getUIASession(authData), [authData]);
export const useUIAErrorCode = (authData: IAuthData) =>
useMemo(() => getUIAErrorCode(authData), [authData]);
export const useUIAError = (authData: IAuthData) =>
useMemo(() => getUIAError(authData), [authData]);
export type StageInfo = Record<string, unknown>;
export type AuthStageData = {
type: string;
info?: StageInfo;
session?: string;
errorCode?: string;
error?: string;
};
export type AuthStageDataGetter = () => AuthStageData | undefined;
export type UIAFlowInterface = {
getStageToComplete: AuthStageDataGetter;
hasStage: (stageType: string) => boolean;
getStageInfo: (stageType: string) => StageInfo | undefined;
};
export const useUIAFlow = (authData: IAuthData, uiaFlow: UIAFlow): UIAFlowInterface => {
const completed = useUIACompleted(authData);
const params = useUIAParams(authData);
const session = useUIASession(authData);
const errorCode = useUIAErrorCode(authData);
const error = useUIAError(authData);
const getStageToComplete: AuthStageDataGetter = useCallback(() => {
const { stages } = uiaFlow;
const nextStage = stages.find((stage) => !completed.includes(stage));
if (!nextStage) return undefined;
const info = params[nextStage];
return {
type: nextStage,
info,
session,
errorCode,
error,
};
}, [uiaFlow, completed, params, errorCode, error, session]);
const hasStage = useCallback(
(stageType: string): boolean => uiaFlow.stages.includes(stageType),
[uiaFlow]
);
const getStageInfo = useCallback(
(stageType: string): StageInfo | undefined => {
if (!hasStage(stageType)) return undefined;
return params[stageType];
},
[hasStage, params]
);
return {
getStageToComplete,
hasStage,
getStageInfo,
};
};

View file

@ -110,7 +110,9 @@ function RoomAliases({ roomId }) {
const canPublishAlias = room.currentState.maySendStateEvent('m.room.canonical_alias', userId);
useEffect(() => isMountedStore.setItem(true), []);
useEffect(() => {
isMountedStore.setItem(true)
}, []);
useEffect(() => {
let isUnmounted = false;

View file

@ -49,7 +49,9 @@ function useVisibility(roomId) {
const room = mx.getRoom(roomId);
const [activeType, setActiveType] = useState(room.getHistoryVisibility());
useEffect(() => setActiveType(room.getHistoryVisibility()), [roomId]);
useEffect(() => {
setActiveType(room.getHistoryVisibility());
}, [roomId]);
const setVisibility = useCallback((item) => {
if (item.type === activeType.type) return;

View file

@ -103,7 +103,9 @@ function setRoomNotifType(roomId, newType) {
function useNotifications(roomId) {
const { notifications } = initMatrix;
const [activeType, setActiveType] = useState(notifications.getNotiType(roomId));
useEffect(() => setActiveType(notifications.getNotiType(roomId)), [roomId]);
useEffect(() => {
setActiveType(notifications.getNotiType(roomId));
}, [roomId]);
const setNotification = useCallback((item) => {
if (item.type === activeType.type) return;

View file

@ -29,7 +29,9 @@ function useRoomSearch(roomId) {
const mountStore = useStore(roomId);
const mx = initMatrix.matrixClient;
useEffect(() => mountStore.setItem(true), [roomId]);
useEffect(() => {
mountStore.setItem(true)
}, [roomId]);
useEffect(() => {
if (searchData?.results?.length > 0) {

View file

@ -50,7 +50,9 @@ function useVisibility(roomId) {
const room = mx.getRoom(roomId);
const [activeType, setActiveType] = useState(room.getJoinRule());
useEffect(() => setActiveType(room.getJoinRule()), [roomId]);
useEffect(() => {
setActiveType(room.getJoinRule());
}, [roomId]);
const setNotification = useCallback((item) => {
if (item.type === activeType.type) return;

View file

@ -80,7 +80,7 @@ function EmojiVerificationContent({ data, requestClose }) {
}
};
if (request === null) return null;
if (request === null) return undefined;
const req = request;
req.on('change', handleChange);
return () => {

View file

@ -23,7 +23,10 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
const [previewStatus, loadPreview] = useAsyncCallback(
useCallback(() => mx.getUrlPreview(url, ts), [url, ts, mx])
);
if (previewStatus.status === AsyncStatus.Idle) loadPreview();
useEffect(() => {
loadPreview();
}, [loadPreview]);
if (previewStatus.status === AsyncStatus.Error) return null;

View file

@ -302,7 +302,9 @@ function SpaceManageContent({ roomId, requestClose }) {
};
}, [roomId]);
useEffect(() => setSelected([]), [spacePath]);
useEffect(() => {
setSelected([]);
}, [spacePath]);
const handleSelected = (selectedRoomId) => {
const newSelected = [...selected];

View file

@ -1,17 +0,0 @@
import React, { StrictMode } from 'react';
import { Provider } from 'jotai';
import { isAuthenticated } from '../../client/state/auth';
import Auth from '../templates/auth/Auth';
import Client from '../templates/client/Client';
function App() {
return (
<StrictMode>
<Provider>{isAuthenticated() ? <Client /> : <Auth />}</Provider>
</StrictMode>
);
}
export default App;

82
src/app/pages/App.tsx Normal file
View file

@ -0,0 +1,82 @@
import React from 'react';
import { Provider as JotaiProvider } from 'jotai';
import {
Route,
RouterProvider,
createBrowserRouter,
createHashRouter,
createRoutesFromElements,
redirect,
} from 'react-router-dom';
import { ClientConfigLoader } from '../components/ClientConfigLoader';
import { ClientConfig, ClientConfigProvider } from '../hooks/useClientConfig';
import { AuthLayout, Login, Register, ResetPassword, authLayoutLoader } from './auth';
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH, ROOT_PATH } from './paths';
import { isAuthenticated } from '../../client/state/auth';
import Client from '../templates/client/Client';
import { getLoginPath } from './pathUtils';
import { ConfigConfigError, ConfigConfigLoading } from './ConfigConfig';
const createRouter = (clientConfig: ClientConfig) => {
const { hashRouter } = clientConfig;
const routes = createRoutesFromElements(
<Route>
<Route
path={ROOT_PATH}
loader={() => {
if (isAuthenticated()) return redirect('/home');
return redirect(getLoginPath());
}}
/>
<Route loader={authLayoutLoader} element={<AuthLayout />}>
<Route path={LOGIN_PATH} element={<Login />} />
<Route path={REGISTER_PATH} element={<Register />} />
<Route path={RESET_PASSWORD_PATH} element={<ResetPassword />} />
</Route>
<Route
loader={() => {
if (!isAuthenticated()) return redirect(getLoginPath());
return null;
}}
>
<Route path="/home" element={<Client />} />
<Route path="/direct" element={<p>direct</p>} />
<Route path="/:spaceIdOrAlias" element={<p>:spaceIdOrAlias</p>} />
<Route path="/explore" element={<p>explore</p>} />
</Route>
<Route path="/*" element={<p>Page not found</p>} />
</Route>
);
if (hashRouter?.enabled) {
return createHashRouter(routes, { basename: hashRouter.basename });
}
return createBrowserRouter(routes, {
basename: import.meta.env.BASE_URL,
});
};
// TODO: app crash boundary
function App() {
return (
<ClientConfigLoader
fallback={() => <ConfigConfigLoading />}
error={(err, retry, ignore) => (
<ConfigConfigError error={err} retry={retry} ignore={ignore} />
)}
>
{(clientConfig) => (
<ClientConfigProvider value={clientConfig}>
<JotaiProvider>
<RouterProvider router={createRouter(clientConfig)} />
</JotaiProvider>
</ClientConfigProvider>
)}
</ClientConfigLoader>
);
}
export default App;

View file

@ -0,0 +1,53 @@
import { Box, Button, Dialog, Spinner, Text, color, config } from 'folds';
import React from 'react';
import { SplashScreen } from '../components/splash-screen';
export function ConfigConfigLoading() {
return (
<SplashScreen>
<Box grow="Yes" direction="Column" gap="400" alignItems="Center" justifyContent="Center">
<Spinner variant="Secondary" size="600" />
<Text>Heating up</Text>
</Box>
</SplashScreen>
);
}
type ConfigConfigErrorProps = {
error: unknown;
retry: () => void;
ignore: () => void;
};
export function ConfigConfigError({ error, retry, ignore }: ConfigConfigErrorProps) {
return (
<SplashScreen>
<Box grow="Yes" direction="Column" gap="400" alignItems="Center" justifyContent="Center">
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text>Failed to load client configuration file.</Text>
{typeof error === 'object' &&
error &&
'message' in error &&
typeof error.message === 'string' && (
<Text size="T300" style={{ color: color.Critical.Main }}>
{error.message}
</Text>
)}
</Box>
<Button variant="Critical" onClick={retry}>
<Text as="span" size="B400">
Retry
</Text>
</Button>
<Button variant="Critical" onClick={ignore} fill="Soft">
<Text as="span" size="B400">
Continue
</Text>
</Button>
</Box>
</Dialog>
</Box>
</SplashScreen>
);
}

View file

@ -0,0 +1,28 @@
import React from 'react';
import { Box, Text } from 'folds';
import * as css from './styles.css';
export function AuthFooter() {
return (
<Box className={css.AuthFooter} justifyContent="Center" gap="400" wrap="Wrap">
<Text as="a" size="T300" href="https://cinny.in" target="_blank" rel="noreferrer">
About
</Text>
<Text
as="a"
size="T300"
href="https://github.com/ajbura/cinny/releases"
target="_blank"
rel="noreferrer"
>
v3.2.0
</Text>
<Text as="a" size="T300" href="https://twitter.com/cinnyapp" target="_blank" rel="noreferrer">
Twitter
</Text>
<Text as="a" size="T300" href="https://matrix.org" target="_blank" rel="noreferrer">
Powered by Matrix
</Text>
</Box>
);
}

View file

@ -0,0 +1,215 @@
import React, { useCallback, useEffect } from 'react';
import { Box, Header, Scroll, Spinner, Text, color } from 'folds';
import {
LoaderFunction,
Outlet,
generatePath,
matchPath,
redirect,
useLocation,
useNavigate,
useParams,
} from 'react-router-dom';
import classNames from 'classnames';
import { AuthFooter } from './AuthFooter';
import * as css from './styles.css';
import * as PatternsCss from '../../styles/Patterns.css';
import { isAuthenticated } from '../../../client/state/auth';
import {
clientAllowedServer,
clientDefaultServer,
useClientConfig,
} from '../../hooks/useClientConfig';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { LOGIN_PATH, REGISTER_PATH } from '../paths';
import CinnySVG from '../../../../public/res/svg/cinny.svg';
import { ServerPicker } from './ServerPicker';
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
import { SpecVersionsProvider } from '../../hooks/useSpecVersions';
import { AutoDiscoveryInfoProvider } from '../../hooks/useAutoDiscoveryInfo';
import { AuthFlowsLoader } from '../../components/AuthFlowsLoader';
import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
import { AuthServerProvider } from '../../hooks/useAuthServer';
export const authLayoutLoader: LoaderFunction = () => {
if (isAuthenticated()) {
return redirect('/');
}
return null;
};
const currentAuthPath = (pathname: string): string => {
if (matchPath(LOGIN_PATH, pathname)) {
return LOGIN_PATH;
}
if (matchPath(REGISTER_PATH, pathname)) {
return REGISTER_PATH;
}
return LOGIN_PATH;
};
function AuthLayoutLoading({ message }: { message: string }) {
return (
<Box justifyContent="Center" alignItems="Center" gap="200">
<Spinner size="100" variant="Secondary" />
<Text align="Center" size="T300">
{message}
</Text>
</Box>
);
}
function AuthLayoutError({ message }: { message: string }) {
return (
<Box justifyContent="Center" alignItems="Center" gap="200">
<Text align="Center" style={{ color: color.Critical.Main }} size="T300">
{message}
</Text>
</Box>
);
}
export function AuthLayout() {
const navigate = useNavigate();
const location = useLocation();
const { server: urlEncodedServer } = useParams();
const clientConfig = useClientConfig();
const defaultServer = clientDefaultServer(clientConfig);
let server: string = urlEncodedServer ? decodeURIComponent(urlEncodedServer) : defaultServer;
if (!clientAllowedServer(clientConfig, server)) {
server = defaultServer;
}
const [discoveryState, discoverServer] = useAsyncCallback(
useCallback(async (serverName: string) => {
const response = await autoDiscovery(fetch, serverName);
return {
serverName,
response,
};
}, [])
);
useEffect(() => {
if (server) discoverServer(server);
}, [discoverServer, server]);
// if server is mismatches with path server, update path
useEffect(() => {
if (!urlEncodedServer || decodeURIComponent(urlEncodedServer) !== server) {
navigate(
generatePath(currentAuthPath(location.pathname), {
server: encodeURIComponent(server),
}),
{ replace: true }
);
}
}, [urlEncodedServer, navigate, location, server]);
const selectServer = useCallback(
(newServer: string) => {
if (newServer === server) {
if (discoveryState.status === AsyncStatus.Loading) return;
discoverServer(server);
return;
}
navigate(
generatePath(currentAuthPath(location.pathname), { server: encodeURIComponent(newServer) })
);
},
[navigate, location, discoveryState, server, discoverServer]
);
const [autoDiscoveryError, autoDiscoveryInfo] =
discoveryState.status === AsyncStatus.Success ? discoveryState.data.response : [];
return (
<Scroll variant="Background" visibility="Hover" size="300" hideTrack>
<Box
className={classNames(css.AuthLayout, PatternsCss.BackgroundDotPattern)}
direction="Column"
alignItems="Center"
justifyContent="SpaceBetween"
gap="400"
>
<Box direction="Column" className={css.AuthCard}>
<Header className={css.AuthHeader} size="600" variant="Surface">
<Box grow="Yes" direction="Row" gap="300" alignItems="Center">
<img className={css.AuthLogo} src={CinnySVG} alt="Cinny Logo" />
<Text size="H3">Cinny</Text>
</Box>
</Header>
<Box className={css.AuthCardContent} direction="Column">
<Box direction="Column" gap="100">
<Text as="label" size="L400" priority="300">
Homeserver
</Text>
<ServerPicker
server={server}
serverList={clientConfig.homeserverList ?? []}
allowCustomServer={clientConfig.allowCustomHomeservers}
onServerChange={selectServer}
/>
</Box>
{discoveryState.status === AsyncStatus.Loading && (
<AuthLayoutLoading message="Looking for homeserver..." />
)}
{discoveryState.status === AsyncStatus.Error && (
<AuthLayoutError message="Failed to find homeserver." />
)}
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_PROMPT && (
<AuthLayoutError
message={`Failed to connect. Homeserver configuration found with ${autoDiscoveryError.host} appears unusable.`}
/>
)}
{autoDiscoveryError?.action === AutoDiscoveryAction.FAIL_ERROR && (
<AuthLayoutError message="Failed to connect. Homeserver configuration base_url appears invalid." />
)}
{discoveryState.status === AsyncStatus.Success && autoDiscoveryInfo && (
<AuthServerProvider value={discoveryState.data.serverName}>
<AutoDiscoveryInfoProvider value={autoDiscoveryInfo}>
<SpecVersionsLoader
fallback={() => (
<AuthLayoutLoading
message={`Connecting to ${autoDiscoveryInfo['m.homeserver'].base_url}`}
/>
)}
error={() => (
<AuthLayoutError message="Failed to connect. Either homeserver is unavailable at this moment or does not exist." />
)}
>
{(specVersions) => (
<SpecVersionsProvider value={specVersions}>
<AuthFlowsLoader
fallback={() => (
<AuthLayoutLoading message="Loading authentication flow..." />
)}
error={() => (
<AuthLayoutError message="Failed to get authentication flow information." />
)}
>
{(authFlows) => (
<AuthFlowsProvider value={authFlows}>
<Outlet />
</AuthFlowsProvider>
)}
</AuthFlowsLoader>
</SpecVersionsProvider>
)}
</SpecVersionsLoader>
</AutoDiscoveryInfoProvider>
</AuthServerProvider>
)}
</Box>
</Box>
<AuthFooter />
</Box>
</Scroll>
);
}

View file

@ -0,0 +1,13 @@
import React from 'react';
import { Box, Icon, Icons, color, Text } from 'folds';
export function FieldError({ message }: { message: string }) {
return (
<Box style={{ color: color.Critical.Main }} alignItems="Center" gap="100">
<Icon size="50" filled src={Icons.Warning} />
<Text size="T200">
<b>{message}</b>
</Text>
</Box>
);
}

View file

@ -0,0 +1,12 @@
import React from 'react';
import { Box, Line, Text } from 'folds';
export function OrDivider() {
return (
<Box gap="400" alignItems="Center">
<Line style={{ flexGrow: 1 }} direction="Horizontal" size="300" variant="Surface" />
<Text>OR</Text>
<Line style={{ flexGrow: 1 }} direction="Horizontal" size="300" variant="Surface" />
</Box>
);
}

View file

@ -0,0 +1,68 @@
import { Avatar, AvatarImage, Box, Button, Text } from 'folds';
import { IIdentityProvider, createClient } from 'matrix-js-sdk';
import React, { useMemo } from 'react';
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
type SSOLoginProps = {
providers: IIdentityProvider[];
asIcons?: boolean;
redirectUrl: string;
};
export function SSOLogin({ providers, redirectUrl, asIcons }: SSOLoginProps) {
const discovery = useAutoDiscoveryInfo();
const baseUrl = discovery['m.homeserver'].base_url;
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
const getSSOIdUrl = (ssoId: string): string => mx.getSsoLoginUrl(redirectUrl, 'sso', ssoId);
return (
<Box justifyContent="Center" gap="600" wrap="Wrap">
{providers.map((provider) => {
const { id, name, icon } = provider;
const iconUrl = icon && mx.mxcUrlToHttp(icon, 96, 96, 'crop', false);
const buttonTitle = `Continue with ${name}`;
if (iconUrl && asIcons) {
return (
<Avatar
style={{ cursor: 'pointer' }}
key={id}
as="a"
href={getSSOIdUrl(id)}
aria-label={buttonTitle}
size="300"
radii="300"
>
<AvatarImage src={iconUrl} alt={name} title={buttonTitle} />
</Avatar>
);
}
return (
<Button
style={{ width: '100%' }}
key={id}
as="a"
href={getSSOIdUrl(id)}
size="500"
variant="Secondary"
fill="Soft"
outlined
before={
iconUrl && (
<Avatar size="200" radii="300">
<AvatarImage src={iconUrl} alt={name} />
</Avatar>
)
}
>
<Text align="Center" size="B500" truncate>
{buttonTitle}
</Text>
</Button>
);
})}
</Box>
);
}

View file

@ -0,0 +1,140 @@
import React, {
ChangeEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useEffect,
useRef,
useState,
} from 'react';
import {
Header,
Icon,
IconButton,
Icons,
Input,
Menu,
MenuItem,
PopOut,
Text,
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useDebounce } from '../../hooks/useDebounce';
export function ServerPicker({
server,
serverList,
allowCustomServer,
onServerChange,
}: {
server: string;
serverList: string[];
allowCustomServer?: boolean;
onServerChange: (server: string) => void;
}) {
const [serverMenu, setServerMenu] = useState(false);
const serverInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
// sync input with it outside server changes
if (serverInputRef.current && serverInputRef.current.value !== server) {
serverInputRef.current.value = server;
}
}, [server]);
const debounceServerSelect = useDebounce(onServerChange, { wait: 700 });
const handleServerChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const inputServer = evt.target.value.trim();
if (inputServer) debounceServerSelect(inputServer);
};
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (evt.key === 'ArrowDown') {
evt.preventDefault();
setServerMenu(true);
}
if (evt.key === 'Enter') {
evt.preventDefault();
const inputServer = evt.currentTarget.value.trim();
if (inputServer) onServerChange(inputServer);
}
};
const handleServerSelect: MouseEventHandler<HTMLButtonElement> = (evt) => {
const selectedServer = evt.currentTarget.getAttribute('data-server');
if (selectedServer) {
onServerChange(selectedServer);
}
setServerMenu(false);
};
return (
<Input
ref={serverInputRef}
style={{ paddingRight: config.space.S200 }}
variant={allowCustomServer ? 'Background' : 'Surface'}
outlined
defaultValue={server}
onChange={handleServerChange}
onKeyDown={handleKeyDown}
size="500"
readOnly={!allowCustomServer}
onClick={allowCustomServer ? undefined : () => setServerMenu(true)}
after={
serverList.length === 0 || (serverList.length === 1 && !allowCustomServer) ? undefined : (
<PopOut
open={serverMenu}
position="Bottom"
align="End"
offset={4}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setServerMenu(false),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
}}
>
<Menu>
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
<Text size="L400">Homeserver List</Text>
</Header>
<div style={{ padding: config.space.S100, paddingTop: 0 }}>
{serverList?.map((serverName) => (
<MenuItem
key={serverName}
radii="300"
aria-pressed={serverName === server}
data-server={serverName}
onClick={handleServerSelect}
>
<Text>{serverName}</Text>
</MenuItem>
))}
</div>
</Menu>
</FocusTrap>
}
>
{(anchorRef) => (
<IconButton
ref={anchorRef}
onClick={() => setServerMenu(true)}
variant={allowCustomServer ? 'Background' : 'Surface'}
size="300"
aria-pressed={serverMenu}
radii="300"
>
<Icon src={Icons.ChevronBottom} />
</IconButton>
)}
</PopOut>
)
}
/>
);
}

View file

@ -0,0 +1,4 @@
export * from './AuthLayout';
export * from './login';
export * from './register';
export * from './reset-password';

View file

@ -0,0 +1,73 @@
import React from 'react';
import { Box, Text, color } from 'folds';
import { Link, useSearchParams } from 'react-router-dom';
import { useAuthFlows } from '../../../hooks/useAuthFlows';
import { useAuthServer } from '../../../hooks/useAuthServer';
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
import { PasswordLoginForm } from './PasswordLoginForm';
import { SSOLogin } from '../SSOLogin';
import { TokenLogin } from './TokenLogin';
import { OrDivider } from '../OrDivider';
import { getLoginPath, getRegisterPath } from '../../pathUtils';
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
import { LoginPathSearchParams } from '../../paths';
const getLoginSearchParams = (searchParams: URLSearchParams): LoginPathSearchParams => ({
username: searchParams.get('username') ?? undefined,
email: searchParams.get('email') ?? undefined,
loginToken: searchParams.get('loginToken') ?? undefined,
});
export function Login() {
const server = useAuthServer();
const { loginFlows } = useAuthFlows();
const [searchParams] = useSearchParams();
const loginSearchParams = getLoginSearchParams(searchParams);
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
const parsedFlows = useParsedLoginFlows(loginFlows.flows);
return (
<Box direction="Column" gap="500">
<Text size="H2" priority="400">
Login
</Text>
{parsedFlows.token && loginSearchParams.loginToken && (
<TokenLogin token={loginSearchParams.loginToken} />
)}
{parsedFlows.password && (
<>
<PasswordLoginForm
defaultUsername={loginSearchParams.username}
defaultEmail={loginSearchParams.email}
/>
<span data-spacing-node />
{parsedFlows.sso && <OrDivider />}
</>
)}
{parsedFlows.sso && (
<>
<SSOLogin
providers={parsedFlows.sso.identity_providers}
redirectUrl={ssoRedirectUrl}
asIcons={
parsedFlows.password !== undefined && parsedFlows.sso.identity_providers.length > 2
}
/>
<span data-spacing-node />
</>
)}
{!parsedFlows.password && !parsedFlows.sso && (
<>
<Text style={{ color: color.Critical.Main }}>
{`This client does not support login on "${server}" homeserver. Password and SSO based login method not found.`}
</Text>
<span data-spacing-node />
</>
)}
<Text align="Center">
Do not have an account? <Link to={getRegisterPath(server)}>Register</Link>
</Text>
</Box>
);
}

View file

@ -0,0 +1,272 @@
import React, { FormEventHandler, useCallback, useState } from 'react';
import {
Box,
Button,
Header,
Icon,
IconButton,
Icons,
Input,
Menu,
Overlay,
OverlayBackdrop,
OverlayCenter,
PopOut,
Spinner,
Text,
config,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { Link } from 'react-router-dom';
import { MatrixError } from 'matrix-js-sdk';
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
import { EMAIL_REGEX } from '../../../utils/regex';
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAuthServer } from '../../../hooks/useAuthServer';
import { useClientConfig } from '../../../hooks/useClientConfig';
import {
CustomLoginResponse,
LoginError,
factoryGetBaseUrl,
login,
useLoginComplete,
} from './loginUtil';
import { PasswordInput } from '../../../components/password-input/PasswordInput';
import { FieldError } from '../FiledError';
import { getResetPasswordPath } from '../../pathUtils';
function UsernameHint({ server }: { server: string }) {
const [open, setOpen] = useState(false);
return (
<PopOut
open={open}
position="Top"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setOpen(false),
clickOutsideDeactivates: true,
}}
>
<Menu>
<Header size="300" style={{ padding: `0 ${config.space.S200}` }}>
<Text size="L400">Hint</Text>
</Header>
<Box
style={{ padding: config.space.S200, paddingTop: 0 }}
direction="Column"
tabIndex={0}
gap="100"
>
<Text size="T300">
<Text as="span" size="Inherit" priority="300">
Username:
</Text>{' '}
johndoe
</Text>
<Text size="T300">
<Text as="span" size="Inherit" priority="300">
Matrix ID:
</Text>
{` @johndoe:${server}`}
</Text>
<Text size="T300">
<Text as="span" size="Inherit" priority="300">
Email:
</Text>
{` johndoe@${server}`}
</Text>
</Box>
</Menu>
</FocusTrap>
}
>
{(targetRef) => (
<IconButton
tabIndex={-1}
onClick={() => setOpen(true)}
ref={targetRef}
type="button"
variant="Background"
size="300"
radii="300"
aria-pressed={open}
>
<Icon style={{ opacity: config.opacity.P300 }} size="100" src={Icons.Info} />
</IconButton>
)}
</PopOut>
);
}
type PasswordLoginFormProps = {
defaultUsername?: string;
defaultEmail?: string;
};
export function PasswordLoginForm({ defaultUsername, defaultEmail }: PasswordLoginFormProps) {
const server = useAuthServer();
const clientConfig = useClientConfig();
const serverDiscovery = useAutoDiscoveryInfo();
const baseUrl = serverDiscovery['m.homeserver'].base_url;
const [loginState, startLogin] = useAsyncCallback<
CustomLoginResponse,
MatrixError,
Parameters<typeof login>
>(useCallback(login, []));
useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined);
const handleUsernameLogin = (username: string, password: string) => {
startLogin(baseUrl, {
type: 'm.login.password',
identifier: {
type: 'm.id.user',
user: username,
},
password,
initial_device_display_name: 'Cinny Web',
});
};
const handleMxIdLogin = async (mxId: string, password: string) => {
const mxIdServer = getMxIdServer(mxId);
const mxIdUsername = getMxIdLocalPart(mxId);
if (!mxIdServer || !mxIdUsername) return;
const getBaseUrl = factoryGetBaseUrl(clientConfig, mxIdServer);
startLogin(getBaseUrl, {
type: 'm.login.password',
identifier: {
type: 'm.id.user',
user: mxIdUsername,
},
password,
initial_device_display_name: 'Cinny Web',
});
};
const handleEmailLogin = (email: string, password: string) => {
startLogin(baseUrl, {
type: 'm.login.password',
identifier: {
type: 'm.id.thirdparty',
medium: 'email',
address: email,
},
password,
initial_device_display_name: 'Cinny Web',
});
};
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const { usernameInput, passwordInput } = evt.target as HTMLFormElement & {
usernameInput: HTMLInputElement;
passwordInput: HTMLInputElement;
};
const username = usernameInput.value.trim();
const password = passwordInput.value;
if (!username) {
usernameInput.focus();
return;
}
if (!password) {
passwordInput.focus();
return;
}
if (isUserId(username)) {
handleMxIdLogin(username, password);
return;
}
if (EMAIL_REGEX.test(username)) {
handleEmailLogin(username, password);
return;
}
handleUsernameLogin(username, password);
};
return (
<Box as="form" onSubmit={handleSubmit} direction="Inherit" gap="400">
<Box direction="Column" gap="100">
<Text as="label" size="L400" priority="300">
Username
</Text>
<Input
defaultValue={defaultUsername ?? defaultEmail}
style={{ paddingRight: config.space.S300 }}
name="usernameInput"
variant="Background"
size="500"
required
outlined
after={<UsernameHint server={server} />}
/>
{loginState.status === AsyncStatus.Error && (
<>
{loginState.error.errcode === LoginError.ServerNotAllowed && (
<FieldError message="Login with custom server not allowed by your client instance." />
)}
{loginState.error.errcode === LoginError.InvalidServer && (
<FieldError message="Failed to find your Matrix ID server." />
)}
</>
)}
</Box>
<Box direction="Column" gap="100">
<Text as="label" size="L400" priority="300">
Password
</Text>
<PasswordInput name="passwordInput" variant="Background" size="500" outlined required />
<Box alignItems="Start" justifyContent="SpaceBetween" gap="200">
{loginState.status === AsyncStatus.Error && (
<>
{loginState.error.errcode === LoginError.Forbidden && (
<FieldError message="Invalid Username or Password." />
)}
{loginState.error.errcode === LoginError.UserDeactivated && (
<FieldError message="This account has been deactivated." />
)}
{loginState.error.errcode === LoginError.InvalidRequest && (
<FieldError message="Failed to login. Part of your request data is invalid." />
)}
{loginState.error.errcode === LoginError.RateLimited && (
<FieldError message="Failed to login. Your login request has been rate-limited by server, Please try after some time." />
)}
{loginState.error.errcode === LoginError.Unknown && (
<FieldError message="Failed to login. Unknown reason." />
)}
</>
)}
<Box grow="Yes" shrink="No" justifyContent="End">
<Text as="span" size="T200" priority="400" align="Right">
<Link to={getResetPasswordPath(server)}>Forget Password?</Link>
</Text>
</Box>
</Box>
</Box>
<Button type="submit" variant="Primary" size="500">
<Text as="span" size="B500">
Login
</Text>
</Button>
<Overlay
open={
loginState.status === AsyncStatus.Loading || loginState.status === AsyncStatus.Success
}
backdrop={<OverlayBackdrop />}
>
<OverlayCenter>
<Spinner variant="Secondary" size="600" />
</OverlayCenter>
</Overlay>
</Box>
);
}

View file

@ -0,0 +1,94 @@
import {
Box,
Icon,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
color,
config,
} from 'folds';
import React, { useCallback, useEffect } from 'react';
import { MatrixError } from 'matrix-js-sdk';
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { CustomLoginResponse, LoginError, login, useLoginComplete } from './loginUtil';
function LoginTokenError({ message }: { message: string }) {
return (
<Box
style={{
backgroundColor: color.Critical.Container,
color: color.Critical.OnContainer,
padding: config.space.S300,
borderRadius: config.radii.R400,
}}
justifyContent="Start"
alignItems="Start"
gap="300"
>
<Icon size="300" filled src={Icons.Warning} />
<Box direction="Column" gap="100">
<Text size="L400">Token Login</Text>
<Text size="T300">
<b>{message}</b>
</Text>
</Box>
</Box>
);
}
type TokenLoginProps = {
token: string;
};
export function TokenLogin({ token }: TokenLoginProps) {
const discovery = useAutoDiscoveryInfo();
const baseUrl = discovery['m.homeserver'].base_url;
const [loginState, startLogin] = useAsyncCallback<
CustomLoginResponse,
MatrixError,
Parameters<typeof login>
>(useCallback(login, []));
useEffect(() => {
startLogin(baseUrl, {
type: 'm.login.token',
token,
initial_device_display_name: 'Cinny Web',
});
}, [baseUrl, token, startLogin]);
useLoginComplete(loginState.status === AsyncStatus.Success ? loginState.data : undefined);
return (
<>
{loginState.status === AsyncStatus.Error && (
<>
{loginState.error.errcode === LoginError.Forbidden && (
<LoginTokenError message="Invalid login token." />
)}
{loginState.error.errcode === LoginError.UserDeactivated && (
<LoginTokenError message="This account has been deactivated." />
)}
{loginState.error.errcode === LoginError.InvalidRequest && (
<LoginTokenError message="Failed to login. Part of your request data is invalid." />
)}
{loginState.error.errcode === LoginError.RateLimited && (
<LoginTokenError message="Failed to login. Your login request has been rate-limited by server, Please try after some time." />
)}
{loginState.error.errcode === LoginError.Unknown && (
<LoginTokenError message="Failed to login. Unknown reason." />
)}
</>
)}
<Overlay open={loginState.status !== AsyncStatus.Error} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<Spinner size="600" variant="Secondary" />
</OverlayCenter>
</Overlay>
</>
);
}

View file

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

View file

@ -0,0 +1,118 @@
import to from 'await-to-js';
import { LoginRequest, LoginResponse, MatrixError, createClient } from 'matrix-js-sdk';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ClientConfig, clientAllowedServer } from '../../../hooks/useClientConfig';
import { autoDiscovery, specVersions } from '../../../cs-api';
import { updateLocalStore } from '../../../../client/action/auth';
import { ROOT_PATH } from '../../paths';
import { ErrorCode } from '../../../cs-errorcode';
export enum GetBaseUrlError {
NotAllow = 'NotAllow',
NotFound = 'NotFound',
}
export const factoryGetBaseUrl = (clientConfig: ClientConfig, server: string) => {
const getBaseUrl = async (): Promise<string> => {
if (!clientAllowedServer(clientConfig, server)) {
throw new Error(GetBaseUrlError.NotAllow);
}
const [, discovery] = await to(autoDiscovery(fetch, server));
let mxIdBaseUrl: string | undefined;
const [, discoveryInfo] = discovery ?? [];
if (discoveryInfo) {
mxIdBaseUrl = discoveryInfo['m.homeserver'].base_url;
}
if (!mxIdBaseUrl) {
throw new Error(GetBaseUrlError.NotFound);
}
const [, versions] = await to(specVersions(fetch, mxIdBaseUrl));
if (!versions) {
throw new Error(GetBaseUrlError.NotFound);
}
return mxIdBaseUrl;
};
return getBaseUrl;
};
export enum LoginError {
ServerNotAllowed = 'ServerNotAllowed',
InvalidServer = 'InvalidServer',
Forbidden = 'Forbidden',
UserDeactivated = 'UserDeactivated',
InvalidRequest = 'InvalidRequest',
RateLimited = 'RateLimited',
Unknown = 'Unknown',
}
export type CustomLoginResponse = {
baseUrl: string;
response: LoginResponse;
};
export const login = async (
serverBaseUrl: string | (() => Promise<string>),
data: LoginRequest
): Promise<CustomLoginResponse> => {
const [urlError, url] =
typeof serverBaseUrl === 'function' ? await to(serverBaseUrl()) : [undefined, serverBaseUrl];
if (urlError) {
throw new MatrixError({
errcode:
urlError.message === GetBaseUrlError.NotAllow
? LoginError.ServerNotAllowed
: LoginError.InvalidServer,
});
}
const mx = createClient({ baseUrl: url });
const [err, res] = await to<LoginResponse, MatrixError>(mx.login(data.type, data));
if (err) {
if (err.httpStatus === 400) {
throw new MatrixError({
errcode: LoginError.InvalidRequest,
});
}
if (err.httpStatus === 429) {
throw new MatrixError({
errcode: LoginError.RateLimited,
});
}
if (err.errcode === ErrorCode.M_USER_DEACTIVATED) {
throw new MatrixError({
errcode: LoginError.UserDeactivated,
});
}
if (err.httpStatus === 403) {
throw new MatrixError({
errcode: LoginError.Forbidden,
});
}
throw new MatrixError({
errcode: LoginError.Unknown,
});
}
return {
baseUrl: url,
response: res,
};
};
export const useLoginComplete = (data?: CustomLoginResponse) => {
const navigate = useNavigate();
useEffect(() => {
if (data) {
const { response: loginRes, baseUrl: loginBaseUrl } = data;
updateLocalStore(loginRes.access_token, loginRes.device_id, loginRes.user_id, loginBaseUrl);
// TODO: add after login redirect url
navigate(ROOT_PATH, { replace: true });
}
}, [data, navigate]);
};

View file

@ -0,0 +1,420 @@
import {
Box,
Button,
Checkbox,
Input,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
color,
} from 'folds';
import React, { ChangeEventHandler, useCallback, useMemo, useState } from 'react';
import {
AuthDict,
AuthType,
IAuthData,
MatrixError,
RegisterRequest,
UIAFlow,
createClient,
} from 'matrix-js-sdk';
import { PasswordInput } from '../../../components/password-input/PasswordInput';
import {
getLoginTermUrl,
getUIAFlowForStages,
hasStageInFlows,
requiredStageInFlows,
} from '../../../utils/matrix-uia';
import { useUIACompleted, useUIAFlow, useUIAParams } from '../../../hooks/useUIAFlows';
import { AsyncState, AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
import { RegisterError, RegisterResult, register, useRegisterComplete } from './registerUtil';
import { FieldError } from '../FiledError';
import {
AutoDummyStageDialog,
AutoTermsStageDialog,
EmailStageDialog,
ReCaptchaStageDialog,
RegistrationTokenStageDialog,
} from '../../../components/uia-stages';
import { useRegisterEmail } from '../../../hooks/useRegisterEmail';
import { ConfirmPasswordMatch } from '../../../components/ConfirmPasswordMatch';
import { UIAFlowOverlay } from '../../../components/UIAFlowOverlay';
import { RequestEmailTokenCallback, RequestEmailTokenResponse } from '../../../hooks/types';
export const SUPPORTED_REGISTER_STAGES = [
AuthType.RegistrationToken,
AuthType.Terms,
AuthType.Recaptcha,
AuthType.Email,
AuthType.Dummy,
];
type RegisterFormInputs = {
usernameInput: HTMLInputElement;
passwordInput: HTMLInputElement;
confirmPasswordInput: HTMLInputElement;
tokenInput?: HTMLInputElement;
emailInput?: HTMLInputElement;
termsInput?: HTMLInputElement;
};
type FormData = {
username: string;
password: string;
token?: string;
email?: string;
terms?: boolean;
clientSecret: string;
};
const pickStages = (uiaFlows: UIAFlow[], formData: FormData): string[] => {
const pickedStages: string[] = [];
if (formData.token) pickedStages.push(AuthType.RegistrationToken);
if (formData.email) pickedStages.push(AuthType.Email);
if (formData.terms) pickedStages.push(AuthType.Terms);
if (hasStageInFlows(uiaFlows, AuthType.Recaptcha)) {
pickedStages.push(AuthType.Recaptcha);
}
return pickedStages;
};
type RegisterUIAFlowProps = {
formData: FormData;
flow: UIAFlow;
authData: IAuthData;
registerEmailState: AsyncState<RequestEmailTokenResponse, MatrixError>;
registerEmail: RequestEmailTokenCallback;
onRegister: (registerReqData: RegisterRequest) => void;
};
function RegisterUIAFlow({
formData,
flow,
authData,
registerEmailState,
registerEmail,
onRegister,
}: RegisterUIAFlowProps) {
const completed = useUIACompleted(authData);
const { getStageToComplete } = useUIAFlow(authData, flow);
const stageToComplete = getStageToComplete();
const handleAuthDict = useCallback(
(authDict: AuthDict) => {
const { password, username } = formData;
onRegister({
auth: authDict,
password,
username,
initial_device_display_name: 'Cinny Web',
});
},
[onRegister, formData]
);
const handleCancel = useCallback(() => {
window.location.reload();
}, []);
if (!stageToComplete) return null;
return (
<UIAFlowOverlay
currentStep={completed.length + 1}
stepCount={flow.stages.length}
onCancel={handleCancel}
>
{stageToComplete.type === AuthType.RegistrationToken && (
<RegistrationTokenStageDialog
token={formData.token}
stageData={stageToComplete}
submitAuthDict={handleAuthDict}
onCancel={handleCancel}
/>
)}
{stageToComplete.type === AuthType.Terms && (
<AutoTermsStageDialog
stageData={stageToComplete}
submitAuthDict={handleAuthDict}
onCancel={handleCancel}
/>
)}
{stageToComplete.type === AuthType.Recaptcha && (
<ReCaptchaStageDialog
stageData={stageToComplete}
submitAuthDict={handleAuthDict}
onCancel={handleCancel}
/>
)}
{stageToComplete.type === AuthType.Email && (
<EmailStageDialog
email={formData.email}
clientSecret={formData.clientSecret}
stageData={stageToComplete}
requestEmailToken={registerEmail}
emailTokenState={registerEmailState}
submitAuthDict={handleAuthDict}
onCancel={handleCancel}
/>
)}
{stageToComplete.type === AuthType.Dummy && (
<AutoDummyStageDialog
stageData={stageToComplete}
submitAuthDict={handleAuthDict}
onCancel={handleCancel}
/>
)}
</UIAFlowOverlay>
);
}
type PasswordRegisterFormProps = {
authData: IAuthData;
uiaFlows: UIAFlow[];
defaultUsername?: string;
defaultEmail?: string;
defaultRegisterToken?: string;
};
export function PasswordRegisterForm({
authData,
uiaFlows,
defaultUsername,
defaultEmail,
defaultRegisterToken,
}: PasswordRegisterFormProps) {
const serverDiscovery = useAutoDiscoveryInfo();
const baseUrl = serverDiscovery['m.homeserver'].base_url;
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
const params = useUIAParams(authData);
const termUrl = getLoginTermUrl(params);
const [formData, setFormData] = useState<FormData>();
const [ongoingFlow, setOngoingFlow] = useState<UIAFlow>();
const [registerEmailState, registerEmail] = useRegisterEmail(mx);
const [registerState, handleRegister] = useAsyncCallback<
RegisterResult,
MatrixError,
[RegisterRequest]
>(useCallback(async (registerReqData) => register(mx, registerReqData), [mx]));
const [ongoingAuthData, customRegisterResp] =
registerState.status === AsyncStatus.Success ? registerState.data : [];
const registerError =
registerState.status === AsyncStatus.Error ? registerState.error : undefined;
useRegisterComplete(customRegisterResp);
const handleSubmit: ChangeEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const {
usernameInput,
passwordInput,
confirmPasswordInput,
emailInput,
tokenInput,
termsInput,
} = evt.target as HTMLFormElement & RegisterFormInputs;
const token = tokenInput?.value.trim();
const username = usernameInput.value.trim();
const password = passwordInput.value;
const confirmPassword = confirmPasswordInput.value;
if (password !== confirmPassword) {
return;
}
const email = emailInput?.value.trim();
const terms = termsInput?.value === 'on';
if (!username) {
usernameInput.focus();
return;
}
const fData: FormData = {
username,
password,
token,
email,
terms,
clientSecret: mx.generateClientSecret(),
};
const pickedStages = pickStages(uiaFlows, fData);
const pickedFlow = getUIAFlowForStages(uiaFlows, pickedStages);
setOngoingFlow(pickedFlow);
setFormData(fData);
handleRegister({
username,
password,
auth: {
session: authData.session,
},
initial_device_display_name: 'Cinny Web',
});
};
return (
<>
<Box as="form" onSubmit={handleSubmit} direction="Inherit" gap="400">
<Box direction="Column" gap="100">
<Text as="label" size="L400" priority="300">
Username
</Text>
<Input
variant="Background"
defaultValue={defaultUsername}
name="usernameInput"
size="500"
outlined
required
/>
{registerError?.errcode === RegisterError.UserTaken && (
<FieldError message="This username is already taken." />
)}
{registerError?.errcode === RegisterError.UserInvalid && (
<FieldError message="This username contains invalid characters." />
)}
{registerError?.errcode === RegisterError.UserExclusive && (
<FieldError message="This username is reserved." />
)}
</Box>
<ConfirmPasswordMatch initialValue>
{(match, doMatch, passRef, confPassRef) => (
<>
<Box direction="Column" gap="100">
<Text as="label" size="L400" priority="300">
Password
</Text>
<PasswordInput
ref={passRef}
onChange={doMatch}
name="passwordInput"
variant="Background"
size="500"
outlined
required
/>
{registerError?.errcode === RegisterError.PasswordWeak && (
<FieldError
message={
registerError.data.error ??
'Weak Password. Password rejected by server please choosing more strong Password.'
}
/>
)}
{registerError?.errcode === RegisterError.PasswordShort && (
<FieldError
message={
registerError.data.error ??
'Short Password. Password rejected by server please choosing more long Password.'
}
/>
)}
</Box>
<Box direction="Column" gap="100">
<Text as="label" size="L400" priority="300">
Confirm Password
</Text>
<PasswordInput
ref={confPassRef}
onChange={doMatch}
name="confirmPasswordInput"
variant="Background"
size="500"
style={{ color: match ? undefined : color.Critical.Main }}
outlined
required
/>
</Box>
</>
)}
</ConfirmPasswordMatch>
{hasStageInFlows(uiaFlows, AuthType.RegistrationToken) && (
<Box direction="Column" gap="100">
<Text as="label" size="L400" priority="300">
{requiredStageInFlows(uiaFlows, AuthType.RegistrationToken)
? 'Registration Token'
: 'Registration Token (Optional)'}
</Text>
<Input
variant="Background"
defaultValue={defaultRegisterToken}
name="tokenInput"
size="500"
required={requiredStageInFlows(uiaFlows, AuthType.RegistrationToken)}
outlined
/>
</Box>
)}
{hasStageInFlows(uiaFlows, AuthType.Email) && (
<Box direction="Column" gap="100">
<Text as="label" size="L400" priority="300">
{requiredStageInFlows(uiaFlows, AuthType.Email) ? 'Email' : 'Email (Optional)'}
</Text>
<Input
variant="Background"
defaultValue={defaultEmail}
name="emailInput"
type="email"
size="500"
required={requiredStageInFlows(uiaFlows, AuthType.Email)}
outlined
/>
</Box>
)}
{hasStageInFlows(uiaFlows, AuthType.Terms) && termUrl && (
<Box alignItems="Center" gap="200">
<Checkbox name="termsInput" size="300" variant="Primary" required />
<Text size="T300">
I accept server{' '}
<a href={termUrl} target="_blank" rel="noreferrer">
Terms and Conditions
</a>
.
</Text>
</Box>
)}
{registerError?.errcode === RegisterError.RateLimited && (
<FieldError message="Failed to register. Your register request has been rate-limited by server, Please try after some time." />
)}
{registerError?.errcode === RegisterError.Forbidden && (
<FieldError message="Failed to register. The homeserver does not permit registration." />
)}
{registerError?.errcode === RegisterError.InvalidRequest && (
<FieldError message="Failed to register. Invalid request." />
)}
{registerError?.errcode === RegisterError.Unknown && (
<FieldError message={registerError.data.error ?? 'Failed to register. Unknown Reason.'} />
)}
<span data-spacing-node />
<Button variant="Primary" size="500" type="submit">
<Text as="span" size="B500">
Register
</Text>
</Button>
</Box>
{registerState.status === AsyncStatus.Success &&
formData &&
ongoingFlow &&
ongoingAuthData && (
<RegisterUIAFlow
formData={formData}
flow={ongoingFlow}
authData={ongoingAuthData}
registerEmail={registerEmail}
registerEmailState={registerEmailState}
onRegister={handleRegister}
/>
)}
{registerState.status === AsyncStatus.Loading && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<Spinner variant="Secondary" size="600" />
</OverlayCenter>
</Overlay>
)}
</>
);
}

View file

@ -0,0 +1,95 @@
import React from 'react';
import { Box, Text, color } from 'folds';
import { Link, useSearchParams } from 'react-router-dom';
import { useAuthServer } from '../../../hooks/useAuthServer';
import { RegisterFlowStatus, useAuthFlows } from '../../../hooks/useAuthFlows';
import { useParsedLoginFlows } from '../../../hooks/useParsedLoginFlows';
import { PasswordRegisterForm, SUPPORTED_REGISTER_STAGES } from '../register/PasswordRegisterForm';
import { OrDivider } from '../OrDivider';
import { SSOLogin } from '../SSOLogin';
import { SupportedUIAFlowsLoader } from '../../../components/SupportedUIAFlowsLoader';
import { getLoginPath } from '../../pathUtils';
import { usePathWithOrigin } from '../../../hooks/usePathWithOrigin';
import { RegisterPathSearchParams } from '../../paths';
const getRegisterSearchParams = (searchParams: URLSearchParams): RegisterPathSearchParams => ({
username: searchParams.get('username') ?? undefined,
email: searchParams.get('email') ?? undefined,
token: searchParams.get('token') ?? undefined,
});
export function Register() {
const server = useAuthServer();
const { loginFlows, registerFlows } = useAuthFlows();
const [searchParams] = useSearchParams();
const registerSearchParams = getRegisterSearchParams(searchParams);
const { sso } = useParsedLoginFlows(loginFlows.flows);
// redirect to /login because only that path handle m.login.token
const ssoRedirectUrl = usePathWithOrigin(getLoginPath(server));
return (
<Box direction="Column" gap="500">
<Text size="H2" priority="400">
Register
</Text>
{registerFlows.status === RegisterFlowStatus.RegistrationDisabled && !sso && (
<Text style={{ color: color.Critical.Main }} size="T300">
Registration has been disabled on this homeserver.
</Text>
)}
{registerFlows.status === RegisterFlowStatus.RateLimited && !sso && (
<Text style={{ color: color.Critical.Main }} size="T300">
You have been rate-limited! Please try after some time.
</Text>
)}
{registerFlows.status === RegisterFlowStatus.InvalidRequest && !sso && (
<Text style={{ color: color.Critical.Main }} size="T300">
Invalid Request! Failed to get any registration options.
</Text>
)}
{registerFlows.status === RegisterFlowStatus.FlowRequired && (
<>
<SupportedUIAFlowsLoader
flows={registerFlows.data.flows ?? []}
supportedStages={SUPPORTED_REGISTER_STAGES}
>
{(supportedFlows) =>
supportedFlows.length === 0 ? (
<Text style={{ color: color.Critical.Main }} size="T300">
This application does not support registration on this homeserver.
</Text>
) : (
<PasswordRegisterForm
authData={registerFlows.data}
uiaFlows={supportedFlows}
defaultUsername={registerSearchParams.username}
defaultEmail={registerSearchParams.email}
defaultRegisterToken={registerSearchParams.token}
/>
)
}
</SupportedUIAFlowsLoader>
<span data-spacing-node />
{sso && <OrDivider />}
</>
)}
{sso && (
<>
<SSOLogin
providers={sso.identity_providers}
redirectUrl={ssoRedirectUrl}
asIcons={
registerFlows.status === RegisterFlowStatus.FlowRequired &&
sso.identity_providers.length > 2
}
/>
<span data-spacing-node />
</>
)}
<Text align="Center">
Already have an account? <Link to={getLoginPath(server)}>Login</Link>
</Text>
</Box>
);
}

View file

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

View file

@ -0,0 +1,125 @@
import to from 'await-to-js';
import {
IAuthData,
MatrixClient,
MatrixError,
RegisterRequest,
RegisterResponse,
} from 'matrix-js-sdk';
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { updateLocalStore } from '../../../../client/action/auth';
import { ROOT_PATH } from '../../paths';
import { ErrorCode } from '../../../cs-errorcode';
export enum RegisterError {
UserTaken = 'UserTaken',
UserInvalid = 'UserInvalid',
UserExclusive = 'UserExclusive',
PasswordWeak = 'PasswordWeak',
PasswordShort = 'PasswordShort',
InvalidRequest = 'InvalidRequest',
Forbidden = 'Forbidden',
RateLimited = 'RateLimited',
Unknown = 'Unknown',
}
export type CustomRegisterResponse = {
baseUrl: string;
response: RegisterResponse;
};
export type RegisterResult = [IAuthData, undefined] | [undefined, CustomRegisterResponse];
export const register = async (
mx: MatrixClient,
requestData: RegisterRequest
): Promise<RegisterResult> => {
const [err, res] = await to<RegisterResponse, MatrixError>(mx.registerRequest(requestData));
if (err) {
if (err.httpStatus === 401) {
const authData = err.data as IAuthData;
return [authData, undefined];
}
if (err.errcode === ErrorCode.M_USER_IN_USE) {
throw new MatrixError({
errcode: RegisterError.UserTaken,
});
}
if (err.errcode === ErrorCode.M_INVALID_USERNAME) {
throw new MatrixError({
errcode: RegisterError.UserInvalid,
});
}
if (err.errcode === ErrorCode.M_EXCLUSIVE) {
throw new MatrixError({
errcode: RegisterError.UserExclusive,
});
}
if (err.errcode === ErrorCode.M_WEAK_PASSWORD) {
throw new MatrixError({
errcode: RegisterError.PasswordWeak,
error: err.data.error,
});
}
if (err.errcode === ErrorCode.M_PASSWORD_TOO_SHORT) {
throw new MatrixError({
errcode: RegisterError.PasswordShort,
error: err.data.error,
});
}
if (err.httpStatus === 429) {
throw new MatrixError({
errcode: RegisterError.RateLimited,
});
}
if (err.httpStatus === 400) {
throw new MatrixError({
errcode: RegisterError.InvalidRequest,
});
}
if (err.httpStatus === 403) {
throw new MatrixError({
errcode: RegisterError.Forbidden,
});
}
throw new MatrixError({
errcode: RegisterError.Unknown,
error: err.data.error,
});
}
return [
undefined,
{
baseUrl: mx.baseUrl,
response: res,
},
];
};
export const useRegisterComplete = (data?: CustomRegisterResponse) => {
const navigate = useNavigate();
useEffect(() => {
if (data) {
const { response, baseUrl } = data;
const userId = response.user_id;
const accessToken = response.access_token;
const deviceId = response.device_id;
if (accessToken && deviceId) {
updateLocalStore(accessToken, deviceId, userId, baseUrl);
// TODO: add after register redirect url
navigate(ROOT_PATH, { replace: true });
} else {
// TODO: navigate to login with userId
navigate(ROOT_PATH, { replace: true });
}
}
}, [data, navigate]);
};

View file

@ -0,0 +1,274 @@
import React, { FormEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
import {
Box,
Button,
Dialog,
Input,
Overlay,
OverlayBackdrop,
OverlayCenter,
Spinner,
Text,
color,
config,
} from 'folds';
import { useNavigate } from 'react-router-dom';
import FocusTrap from 'focus-trap-react';
import { AuthDict, AuthType, MatrixError, createClient } from 'matrix-js-sdk';
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAuthServer } from '../../../hooks/useAuthServer';
import { usePasswordEmail } from '../../../hooks/usePasswordEmail';
import { PasswordInput } from '../../../components/password-input/PasswordInput';
import { ConfirmPasswordMatch } from '../../../components/ConfirmPasswordMatch';
import { FieldError } from '../FiledError';
import { UIAFlowOverlay } from '../../../components/UIAFlowOverlay';
import { EmailStageDialog } from '../../../components/uia-stages';
import { ResetPasswordResult, resetPassword } from './resetPasswordUtil';
import { getLoginPath, withSearchParam } from '../../pathUtils';
import { LoginPathSearchParams } from '../../paths';
import { getUIAError, getUIAErrorCode } from '../../../utils/matrix-uia';
type FormData = {
email: string;
password: string;
clientSecret: string;
};
function ResetPasswordComplete({ email }: { email?: string }) {
const server = useAuthServer();
const navigate = useNavigate();
const handleClick = () => {
const path = getLoginPath(server);
if (email) {
navigate(withSearchParam<LoginPathSearchParams>(path, { email }));
return;
}
navigate(path);
};
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap>
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Text>
Password has been reset successfully. Please login with your new password.
</Text>
<Button variant="Primary" onClick={handleClick}>
<Text size="B400" as="span">
Login
</Text>
</Button>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}
type PasswordResetFormProps = {
defaultEmail?: string;
};
export function PasswordResetForm({ defaultEmail }: PasswordResetFormProps) {
const server = useAuthServer();
const serverDiscovery = useAutoDiscoveryInfo();
const baseUrl = serverDiscovery['m.homeserver'].base_url;
const mx = useMemo(() => createClient({ baseUrl }), [baseUrl]);
const [formData, setFormData] = useState<FormData>();
const [passwordEmailState, passwordEmail] = usePasswordEmail(mx);
const [resetPasswordState, handleResetPassword] = useAsyncCallback<
ResetPasswordResult,
MatrixError,
[AuthDict, string]
>(useCallback(async (authDict, newPassword) => resetPassword(mx, authDict, newPassword), [mx]));
const [ongoingAuthData, resetPasswordResult] =
resetPasswordState.status === AsyncStatus.Success ? resetPasswordState.data : [];
const resetPasswordError =
resetPasswordState.status === AsyncStatus.Error ? resetPasswordState.error : undefined;
const flowErrorCode = ongoingAuthData && getUIAErrorCode(ongoingAuthData);
const flowError = ongoingAuthData && getUIAError(ongoingAuthData);
let waitingToVerifyEmail = true;
if (resetPasswordResult) waitingToVerifyEmail = false;
if (ongoingAuthData && flowErrorCode === undefined) waitingToVerifyEmail = false;
if (resetPasswordError) waitingToVerifyEmail = false;
if (resetPasswordState.status === AsyncStatus.Loading) waitingToVerifyEmail = false;
// We only support UIA m.login.password stage for reset password
// So we will assume to process it as soon as
// we have 401 with no error on initial request.
useEffect(() => {
if (formData && ongoingAuthData && !flowErrorCode) {
handleResetPassword(
{
type: AuthType.Password,
identifier: {
type: 'm.id.thirdparty',
medium: 'email',
address: formData.email,
},
password: formData.password,
},
formData.password
);
}
}, [ongoingAuthData, flowErrorCode, formData, handleResetPassword]);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const { emailInput, passwordInput, confirmPasswordInput } = evt.target as HTMLFormElement & {
emailInput: HTMLInputElement;
passwordInput: HTMLInputElement;
confirmPasswordInput: HTMLInputElement;
};
const email = emailInput.value.trim();
const password = passwordInput.value;
const confirmPassword = confirmPasswordInput.value;
if (!email) {
emailInput.focus();
return;
}
if (password !== confirmPassword) return;
const clientSecret = mx.generateClientSecret();
passwordEmail(email, clientSecret);
setFormData({
email,
password,
clientSecret,
});
};
const handleCancel = () => {
window.location.reload();
};
const handleSubmitRequest = useCallback(
(authDict: AuthDict) => {
if (!formData) return;
const { password } = formData;
handleResetPassword(authDict, password);
},
[formData, handleResetPassword]
);
return (
<Box as="form" onSubmit={handleSubmit} direction="Inherit" gap="400">
<Text size="T300" priority="400">
Homeserver <strong>{server}</strong> will send you an email to let you reset your password.
</Text>
<Box direction="Column" gap="100">
<Text as="label" size="L400" priority="300">
Email
</Text>
<Input
defaultValue={defaultEmail}
type="email"
name="emailInput"
variant="Background"
size="500"
required
outlined
/>
{passwordEmailState.status === AsyncStatus.Error && (
<FieldError
message={`${passwordEmailState.error.errcode}: ${passwordEmailState.error.data?.error}`}
/>
)}
</Box>
<ConfirmPasswordMatch initialValue>
{(match, doMatch, passRef, confPassRef) => (
<>
<Box direction="Column" gap="100">
<Text as="label" size="L400" priority="300">
New Password
</Text>
<PasswordInput
ref={passRef}
onChange={doMatch}
name="passwordInput"
variant="Background"
size="500"
outlined
required
/>
</Box>
<Box direction="Column" gap="100">
<Text as="label" size="L400" priority="300">
Confirm Password
</Text>
<PasswordInput
ref={confPassRef}
onChange={doMatch}
name="confirmPasswordInput"
variant="Background"
size="500"
style={{ color: match ? undefined : color.Critical.Main }}
outlined
required
/>
</Box>
</>
)}
</ConfirmPasswordMatch>
{resetPasswordError && (
<FieldError
message={`${resetPasswordError.errcode}: ${
resetPasswordError.data?.error ?? 'Failed to reset password.'
}`}
/>
)}
<span data-spacing-node />
<Button type="submit" variant="Primary" size="500">
<Text as="span" size="B500">
Reset Password
</Text>
</Button>
{resetPasswordResult && <ResetPasswordComplete email={formData?.email} />}
{passwordEmailState.status === AsyncStatus.Success && formData && waitingToVerifyEmail && (
<UIAFlowOverlay currentStep={1} stepCount={1} onCancel={handleCancel}>
<EmailStageDialog
stageData={{
type: AuthType.Email,
errorCode: flowErrorCode,
error: flowError,
session: ongoingAuthData?.session,
}}
submitAuthDict={handleSubmitRequest}
email={formData.email}
clientSecret={formData.clientSecret}
requestEmailToken={passwordEmail}
emailTokenState={passwordEmailState}
onCancel={handleCancel}
/>
</UIAFlowOverlay>
)}
<Overlay
open={
passwordEmailState.status === AsyncStatus.Loading ||
resetPasswordState.status === AsyncStatus.Loading
}
backdrop={<OverlayBackdrop />}
>
<OverlayCenter>
<Spinner variant="Secondary" size="600" />
</OverlayCenter>
</Overlay>
</Box>
);
}

View file

@ -0,0 +1,36 @@
import { Box, Text } from 'folds';
import React from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { getLoginPath } from '../../pathUtils';
import { useAuthServer } from '../../../hooks/useAuthServer';
import { PasswordResetForm } from './PasswordResetForm';
export type ResetPasswordSearchParams = {
email?: string;
};
const getResetPasswordSearchParams = (
searchParams: URLSearchParams
): ResetPasswordSearchParams => ({
email: searchParams.get('email') ?? undefined,
});
export function ResetPassword() {
const server = useAuthServer();
const [searchParams] = useSearchParams();
const resetPasswordSearchParams = getResetPasswordSearchParams(searchParams);
return (
<Box direction="Column" gap="500">
<Text size="H2" priority="400">
Reset Password
</Text>
<PasswordResetForm defaultEmail={resetPasswordSearchParams.email} />
<span data-spacing-node />
<Text align="Center">
Remember your password? <Link to={getLoginPath(server)}>Login</Link>
</Text>
</Box>
);
}

View file

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

View file

@ -0,0 +1,23 @@
import to from 'await-to-js';
import { AuthDict, IAuthData, MatrixClient, MatrixError } from 'matrix-js-sdk';
export type ResetPasswordResponse = Record<string, never>;
export type ResetPasswordResult = [IAuthData, undefined] | [undefined, ResetPasswordResponse];
export const resetPassword = async (
mx: MatrixClient,
authDict: AuthDict,
newPassword: string
): Promise<ResetPasswordResult> => {
const [err, res] = await to<ResetPasswordResponse, MatrixError>(
mx.setPassword(authDict, newPassword, false)
);
if (err) {
if (err.httpStatus === 401) {
const authData = err.data as IAuthData;
return [authData, undefined];
}
throw err;
}
return [undefined, res];
};

View file

@ -0,0 +1,53 @@
import { style } from '@vanilla-extract/css';
import { DefaultReset, color, config, toRem } from 'folds';
export const AuthLayout = style({
minHeight: '100%',
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
padding: config.space.S400,
paddingRight: config.space.S200,
paddingBottom: 0,
position: 'relative',
});
export const AuthCard = style({
marginTop: '1vh',
maxWidth: toRem(460),
width: '100%',
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
borderRadius: config.radii.R400,
boxShadow: config.shadow.E100,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
overflow: 'hidden',
});
export const AuthLogo = style([
DefaultReset,
{
width: toRem(26),
height: toRem(26),
borderRadius: '50%',
},
]);
export const AuthHeader = style({
padding: `0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
});
export const AuthCardContent = style({
maxWidth: toRem(402),
width: '100%',
margin: 'auto',
padding: config.space.S400,
paddingTop: config.space.S700,
paddingBottom: toRem(44),
gap: toRem(44),
});
export const AuthFooter = style({
padding: config.space.S200,
});

View file

@ -0,0 +1,28 @@
import { generatePath } from 'react-router-dom';
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH, ROOT_PATH } from './paths';
export const withSearchParam = <T extends Record<string, string>>(
path: string,
searchParam: T
): string => {
const params = new URLSearchParams(searchParam);
return `${path}?${params}`;
};
export const getRootPath = (): string => ROOT_PATH;
export const getLoginPath = (server?: string): string => {
const params = server ? { server: encodeURIComponent(server) } : undefined;
return generatePath(LOGIN_PATH, params);
};
export const getRegisterPath = (server?: string): string => {
const params = server ? { server: encodeURIComponent(server) } : undefined;
return generatePath(REGISTER_PATH, params);
};
export const getResetPasswordPath = (server?: string): string => {
const params = server ? { server: encodeURIComponent(server) } : undefined;
return generatePath(RESET_PASSWORD_PATH, params);
};

17
src/app/pages/paths.ts Normal file
View file

@ -0,0 +1,17 @@
export const ROOT_PATH = '/';
export type LoginPathSearchParams = {
username?: string;
email?: string;
loginToken?: string;
};
export const LOGIN_PATH = '/login/:server?/';
export type RegisterPathSearchParams = {
username?: string;
email?: string;
token?: string;
};
export const REGISTER_PATH = '/register/:server?/';
export const RESET_PASSWORD_PATH = '/reset-password/:server?/';

View file

@ -1,28 +1,26 @@
import { useAtomValue, WritableAtom } from 'jotai';
import { useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { MatrixClient } from 'matrix-js-sdk';
import { useCallback } from 'react';
import { isDirectInvite, isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
import { compareRoomsEqual, RoomsAction } from '../utils';
import { MDirectAction } from '../mDirectList';
import { compareRoomsEqual } from '../utils';
import { mDirectAtom } from '../mDirectList';
import { allInvitesAtom } from '../inviteList';
export const useSpaceInvites = (
mx: MatrixClient,
allInvitesAtom: WritableAtom<string[], RoomsAction>
) => {
export const useSpaceInvites = (mx: MatrixClient, invitesAtom: typeof allInvitesAtom) => {
const selector = useCallback(
(rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))),
[mx]
);
return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
};
export const useRoomInvites = (
mx: MatrixClient,
allInvitesAtom: WritableAtom<string[], RoomsAction>,
mDirectAtom: WritableAtom<Set<string>, MDirectAction>
invitesAtom: typeof allInvitesAtom,
directAtom: typeof mDirectAtom
) => {
const mDirects = useAtomValue(mDirectAtom);
const mDirects = useAtomValue(directAtom);
const selector = useCallback(
(rooms: string[]) =>
rooms.filter(
@ -32,15 +30,15 @@ export const useRoomInvites = (
),
[mx, mDirects]
);
return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
};
export const useDirectInvites = (
mx: MatrixClient,
allInvitesAtom: WritableAtom<string[], RoomsAction>,
mDirectAtom: WritableAtom<Set<string>, MDirectAction>
invitesAtom: typeof allInvitesAtom,
directAtom: typeof mDirectAtom
) => {
const mDirects = useAtomValue(mDirectAtom);
const mDirects = useAtomValue(directAtom);
const selector = useCallback(
(rooms: string[]) =>
rooms.filter(
@ -48,16 +46,13 @@ export const useDirectInvites = (
),
[mx, mDirects]
);
return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
};
export const useUnsupportedInvites = (
mx: MatrixClient,
allInvitesAtom: WritableAtom<string[], RoomsAction>
) => {
export const useUnsupportedInvites = (mx: MatrixClient, invitesAtom: typeof allInvitesAtom) => {
const selector = useCallback(
(rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
[mx]
);
return useAtomValue(selectAtom(allInvitesAtom, selector, compareRoomsEqual));
return useAtomValue(selectAtom(invitesAtom, selector, compareRoomsEqual));
};

View file

@ -1,54 +1,52 @@
import { useAtomValue, WritableAtom } from 'jotai';
import { useAtomValue } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { MatrixClient } from 'matrix-js-sdk';
import { useCallback } from 'react';
import { isRoom, isSpace, isUnsupportedRoom } from '../../utils/room';
import { compareRoomsEqual, RoomsAction } from '../utils';
import { MDirectAction } from '../mDirectList';
import { compareRoomsEqual } from '../utils';
import { mDirectAtom } from '../mDirectList';
import { allRoomsAtom } from '../roomList';
export const useSpaces = (mx: MatrixClient, allRoomsAtom: WritableAtom<string[], RoomsAction>) => {
export const useSpaces = (mx: MatrixClient, roomsAtom: typeof allRoomsAtom) => {
const selector = useCallback(
(rooms: string[]) => rooms.filter((roomId) => isSpace(mx.getRoom(roomId))),
[mx]
);
return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
};
export const useRooms = (
mx: MatrixClient,
allRoomsAtom: WritableAtom<string[], RoomsAction>,
mDirectAtom: WritableAtom<Set<string>, MDirectAction>
roomsAtom: typeof allRoomsAtom,
directAtom: typeof mDirectAtom
) => {
const mDirects = useAtomValue(mDirectAtom);
const mDirects = useAtomValue(directAtom);
const selector = useCallback(
(rooms: string[]) =>
rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && !mDirects.has(roomId)),
[mx, mDirects]
);
return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
};
export const useDirects = (
mx: MatrixClient,
allRoomsAtom: WritableAtom<string[], RoomsAction>,
mDirectAtom: WritableAtom<Set<string>, MDirectAction>
roomsAtom: typeof allRoomsAtom,
directAtom: typeof mDirectAtom
) => {
const mDirects = useAtomValue(mDirectAtom);
const mDirects = useAtomValue(directAtom);
const selector = useCallback(
(rooms: string[]) =>
rooms.filter((roomId) => isRoom(mx.getRoom(roomId)) && mDirects.has(roomId)),
[mx, mDirects]
);
return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
};
export const useUnsupportedRooms = (
mx: MatrixClient,
allRoomsAtom: WritableAtom<string[], RoomsAction>
) => {
export const useUnsupportedRooms = (mx: MatrixClient, roomsAtom: typeof allRoomsAtom) => {
const selector = useCallback(
(rooms: string[]) => rooms.filter((roomId) => isUnsupportedRoom(mx.getRoom(roomId))),
[mx]
);
return useAtomValue(selectAtom(allRoomsAtom, selector, compareRoomsEqual));
return useAtomValue(selectAtom(roomsAtom, selector, compareRoomsEqual));
};

View file

@ -1,16 +1,16 @@
import { atom, useAtomValue, useSetAtom, WritableAtom } from 'jotai';
import { SetAtom } from 'jotai/core/atom';
import { atom, useAtomValue, useSetAtom } from 'jotai';
import { selectAtom } from 'jotai/utils';
import { useMemo } from 'react';
import { Settings } from '../settings';
import { Settings, settingsAtom as sAtom } from '../settings';
export const useSetSetting = <K extends keyof Settings>(
settingsAtom: WritableAtom<Settings, Settings>,
key: K
) => {
export type SettingSetter<K extends keyof Settings> =
| Settings[K]
| ((s: Settings[K]) => Settings[K]);
export const useSetSetting = <K extends keyof Settings>(settingsAtom: typeof sAtom, key: K) => {
const setterAtom = useMemo(
() =>
atom<null, Settings[K] | ((s: Settings[K]) => Settings[K])>(null, (get, set, value) => {
atom<null, [SettingSetter<K>], undefined>(null, (get, set, value) => {
const s = { ...get(settingsAtom) };
s[key] = typeof value === 'function' ? value(s[key]) : value;
set(settingsAtom, s);
@ -22,9 +22,9 @@ export const useSetSetting = <K extends keyof Settings>(
};
export const useSetting = <K extends keyof Settings>(
settingsAtom: WritableAtom<Settings, Settings>,
settingsAtom: typeof sAtom,
key: K
): [Settings[K], SetAtom<Settings[K] | ((s: Settings[K]) => Settings[K]), void>] => {
): [Settings[K], ReturnType<typeof useSetSetting<K>>] => {
const selector = useMemo(() => (s: Settings) => s[key], [key]);
const setting = useAtomValue(selectAtom(settingsAtom, selector));

View file

@ -5,7 +5,7 @@ import { Membership } from '../../types/matrix/room';
import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
const baseRoomsAtom = atom<string[]>([]);
export const allInvitesAtom = atom<string[], RoomsAction>(
export const allInvitesAtom = atom<string[], [RoomsAction], undefined>(
(get) => get(baseRoomsAtom),
(get, set, action) => {
if (action.type === 'INITIALIZE') {
@ -22,7 +22,7 @@ export const allInvitesAtom = atom<string[], RoomsAction>(
export const useBindAllInvitesAtom = (
mx: MatrixClient,
allRooms: WritableAtom<string[], RoomsAction>
allRooms: WritableAtom<string[], [RoomsAction], undefined>
) => {
useBindRoomsWithMembershipsAtom(
mx,

View file

@ -12,7 +12,7 @@ export type ListAction<T> =
export const createListAtom = <T>() => {
const baseListAtom = atom<T[]>([]);
return atom<T[], ListAction<T>>(
return atom<T[], [ListAction<T>], undefined>(
(get) => get(baseListAtom),
(get, set, action) => {
const items = get(baseListAtom);

View file

@ -1,4 +1,4 @@
import { atom, useSetAtom, WritableAtom } from 'jotai';
import { atom, useSetAtom } from 'jotai';
import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { useEffect } from 'react';
import { AccountDataEvent } from '../../types/matrix/accountData';
@ -10,17 +10,14 @@ export type MDirectAction = {
};
const baseMDirectAtom = atom(new Set<string>());
export const mDirectAtom = atom<Set<string>, MDirectAction>(
export const mDirectAtom = atom<Set<string>, [MDirectAction], undefined>(
(get) => get(baseMDirectAtom),
(get, set, action) => {
set(baseMDirectAtom, action.rooms);
}
);
export const useBindMDirectAtom = (
mx: MatrixClient,
mDirect: WritableAtom<Set<string>, MDirectAction>
) => {
export const useBindMDirectAtom = (mx: MatrixClient, mDirect: typeof mDirectAtom) => {
const setMDirect = useSetAtom(mDirect);
useEffect(() => {

View file

@ -1,4 +1,4 @@
import { atom, WritableAtom, useSetAtom } from 'jotai';
import { atom, useSetAtom } from 'jotai';
import { ClientEvent, IPushRule, IPushRules, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { useEffect } from 'react';
import { MuteChanges } from '../../types/matrix/room';
@ -21,7 +21,7 @@ export const muteChangesAtom = atom<MuteChanges>({
});
const baseMutedRoomsAtom = atom(new Set<string>());
export const mutedRoomsAtom = atom<Set<string>, MutedRoomsUpdate>(
export const mutedRoomsAtom = atom<Set<string>, [MutedRoomsUpdate], undefined>(
(get) => get(baseMutedRoomsAtom),
(get, set, action) => {
const mutedRooms = new Set([...get(mutedRoomsAtom)]);
@ -45,10 +45,7 @@ export const mutedRoomsAtom = atom<Set<string>, MutedRoomsUpdate>(
}
);
export const useBindMutedRoomsAtom = (
mx: MatrixClient,
mutedAtom: WritableAtom<Set<string>, MutedRoomsUpdate>
) => {
export const useBindMutedRoomsAtom = (mx: MatrixClient, mutedAtom: typeof mutedRoomsAtom) => {
const setMuted = useSetAtom(mutedAtom);
useEffect(() => {

View file

@ -1,11 +1,11 @@
import { atom, WritableAtom } from 'jotai';
import { atom } from 'jotai';
import { MatrixClient } from 'matrix-js-sdk';
import { useMemo } from 'react';
import { Membership } from '../../types/matrix/room';
import { RoomsAction, useBindRoomsWithMembershipsAtom } from './utils';
const baseRoomsAtom = atom<string[]>([]);
export const allRoomsAtom = atom<string[], RoomsAction>(
export const allRoomsAtom = atom<string[], [RoomsAction], undefined>(
(get) => get(baseRoomsAtom),
(get, set, action) => {
if (action.type === 'INITIALIZE') {
@ -19,10 +19,7 @@ export const allRoomsAtom = atom<string[], RoomsAction>(
});
}
);
export const useBindAllRoomsAtom = (
mx: MatrixClient,
allRooms: WritableAtom<string[], RoomsAction>
) => {
export const useBindAllRoomsAtom = (mx: MatrixClient, allRooms: typeof allRoomsAtom) => {
useBindRoomsWithMembershipsAtom(
mx,
allRooms,

View file

@ -1,5 +1,5 @@
import produce from 'immer';
import { atom, useSetAtom, WritableAtom } from 'jotai';
import { atom, useSetAtom } from 'jotai';
import {
ClientEvent,
MatrixClient,
@ -34,7 +34,7 @@ export type RoomToParentsAction =
};
const baseRoomToParents = atom<RoomToParents>(new Map());
export const roomToParentsAtom = atom<RoomToParents, RoomToParentsAction>(
export const roomToParentsAtom = atom<RoomToParents, [RoomToParentsAction], undefined>(
(get) => get(baseRoomToParents),
(get, set, action) => {
if (action.type === 'INITIALIZE') {
@ -69,7 +69,7 @@ export const roomToParentsAtom = atom<RoomToParents, RoomToParentsAction>(
export const useBindRoomToParentsAtom = (
mx: MatrixClient,
roomToParents: WritableAtom<RoomToParents, RoomToParentsAction>
roomToParents: typeof roomToParentsAtom
) => {
const setRoomToParents = useSetAtom(roomToParents);

View file

@ -1,5 +1,5 @@
import produce from 'immer';
import { atom, useSetAtom, PrimitiveAtom, WritableAtom, useAtomValue } from 'jotai';
import { atom, useSetAtom, PrimitiveAtom, useAtomValue } from 'jotai';
import { IRoomTimelineData, MatrixClient, MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
import { ReceiptContent, ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
import { useEffect } from 'react';
@ -82,7 +82,7 @@ const deleteUnreadInfo = (roomToUnread: RoomToUnread, allParents: Set<string>, r
};
const baseRoomToUnread = atom<RoomToUnread>(new Map());
export const roomToUnreadAtom = atom<RoomToUnread, RoomToUnreadAction>(
export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefined>(
(get) => get(baseRoomToUnread),
(get, set, action) => {
if (action.type === 'RESET') {
@ -127,7 +127,7 @@ export const roomToUnreadAtom = atom<RoomToUnread, RoomToUnreadAction>(
export const useBindRoomToUnreadAtom = (
mx: MatrixClient,
unreadAtom: WritableAtom<RoomToUnread, RoomToUnreadAction>,
unreadAtom: typeof roomToUnreadAtom,
muteChangesAtom: PrimitiveAtom<MuteChanges>
) => {
const setUnreadAtom = useSetAtom(unreadAtom);

129
src/app/state/sessions.ts Normal file
View file

@ -0,0 +1,129 @@
import { atom } from 'jotai';
import {
atomWithLocalStorage,
getLocalStorageItem,
setLocalStorageItem,
} from './utils/atomWithLocalStorage';
export type Session = {
baseUrl: string;
userId: string;
deviceId: string;
accessToken: string;
expiresInMs?: number;
refreshToken?: string;
fallbackSdkStores?: boolean;
};
export type Sessions = Session[];
export type SessionStoreName = {
sync: string;
crypto: string;
};
/**
* Migration code for old session
*/
const FALLBACK_STORE_NAME: SessionStoreName = {
sync: 'web-sync-store',
crypto: 'crypto-store',
} as const;
const removeFallbackSession = () => {
localStorage.removeItem('cinny_hs_base_url');
localStorage.removeItem('cinny_user_id');
localStorage.removeItem('cinny_device_id');
localStorage.removeItem('cinny_access_token');
};
const getFallbackSession = (): Session | undefined => {
const baseUrl = localStorage.getItem('cinny_hs_base_url');
const userId = localStorage.getItem('cinny_user_id');
const deviceId = localStorage.getItem('cinny_device_id');
const accessToken = localStorage.getItem('cinny_access_token');
if (baseUrl && userId && deviceId && accessToken) {
const session: Session = {
baseUrl,
userId,
deviceId,
accessToken,
fallbackSdkStores: true,
};
return session;
}
return undefined;
};
/**
* End of migration code for old session
*/
export const getSessionStoreName = (session: Session): SessionStoreName => {
if (session.fallbackSdkStores) {
return FALLBACK_STORE_NAME;
}
return {
sync: `sync${session.userId}`,
crypto: `crypto${session.userId}`,
};
};
export const MATRIX_SESSIONS_KEY = 'matrixSessions';
const baseSessionsAtom = atomWithLocalStorage<Sessions>(
MATRIX_SESSIONS_KEY,
(key) => {
const defaultSessions: Sessions = [];
const sessions = getLocalStorageItem(key, defaultSessions);
// Before multi account support session was stored
// as multiple item in local storage.
// So we need these migration code.
const fallbackSession = getFallbackSession();
if (fallbackSession) {
removeFallbackSession();
sessions.push(fallbackSession);
setLocalStorageItem(key, sessions);
}
return sessions;
},
(key, value) => {
setLocalStorageItem(key, value);
}
);
export type SessionsAction =
| {
type: 'PUT';
session: Session;
}
| {
type: 'DELETE';
session: Session;
};
export const sessionsAtom = atom<Sessions, [SessionsAction], undefined>(
(get) => get(baseSessionsAtom),
(get, set, action) => {
if (action.type === 'PUT') {
const sessions = [...get(baseSessionsAtom)];
const sessionIndex = sessions.findIndex(
(session) => session.userId === action.session.userId
);
if (sessionIndex === -1) {
sessions.push(action.session);
} else {
sessions.splice(sessionIndex, 1, action.session);
}
set(baseSessionsAtom, sessions);
return;
}
if (action.type === 'DELETE') {
const sessions = get(baseSessionsAtom).filter(
(session) => session.userId !== action.session.userId
);
set(baseSessionsAtom, sessions);
}
}
);

View file

@ -64,7 +64,7 @@ export const setSettings = (settings: Settings) => {
};
const baseSettings = atom<Settings>(getSettings());
export const settingsAtom = atom<Settings, Settings>(
export const settingsAtom = atom<Settings, [Settings], undefined>(
(get) => get(baseSettings),
(get, set, update) => {
set(baseSettings, update);

View file

@ -14,7 +14,7 @@ type TabToRoomAction = {
};
const baseTabToRoom = atom<TabToRoom>(new Map());
export const tabToRoomAtom = atom<TabToRoom, TabToRoomAction>(
export const tabToRoomAtom = atom<TabToRoom, [TabToRoomAction], undefined>(
(get) => get(baseTabToRoom),
(get, set, action) => {
if (action.type === 'PUT') {

View file

@ -23,7 +23,11 @@ export type IRoomIdToTypingMembersAction =
};
const baseRoomIdToTypingMembersAtom = atom<IRoomIdToTypingMembers>(new Map());
export const roomIdToTypingMembersAtom = atom<IRoomIdToTypingMembers, IRoomIdToTypingMembersAction>(
export const roomIdToTypingMembersAtom = atom<
IRoomIdToTypingMembers,
[IRoomIdToTypingMembersAction],
undefined
>(
(get) => get(baseRoomIdToTypingMembersAtom),
(get, set, action) => {
const roomIdToTypingMembers = get(baseRoomIdToTypingMembersAtom);

View file

@ -57,7 +57,7 @@ export const createUploadAtom = (file: TUploadContent) => {
file,
status: UploadStatus.Idle,
});
return atom<Upload, UploadAtomAction>(
return atom<Upload, [UploadAtomAction], undefined>(
(get) => get(baseUploadAtom),
(get, set, update) => {
const uploadState = get(baseUploadAtom);

View file

@ -15,7 +15,7 @@ export type RoomsAction =
export const useBindRoomsWithMembershipsAtom = (
mx: MatrixClient,
roomsAtom: WritableAtom<string[], RoomsAction>,
roomsAtom: WritableAtom<string[], [RoomsAction], undefined>,
memberships: Membership[]
) => {
const setRoomsAtom = useSetAtom(roomsAtom);

View file

@ -0,0 +1,51 @@
import { atom } from 'jotai';
export const getLocalStorageItem = <T>(key: string, defaultValue: T): T => {
const item = localStorage.getItem(key);
if (item === null) return defaultValue;
if (item === 'undefined') return undefined as T;
try {
return JSON.parse(item) as T;
} catch {
return defaultValue;
}
};
export const setLocalStorageItem = <T>(key: string, value: T) => {
localStorage.setItem(key, JSON.stringify(value));
};
export type GetLocalStorageItem<T> = (key: string) => T;
export type SetLocalStorageItem<T> = (key: string, value: T) => void;
export const atomWithLocalStorage = <T>(
key: string,
getItem: GetLocalStorageItem<T>,
setItem: SetLocalStorageItem<T>
) => {
const value = getItem(key);
const baseAtom = atom<T>(value);
baseAtom.onMount = (setAtom) => {
const handleChange = (evt: StorageEvent) => {
if (evt.key !== key) return;
setAtom(getItem(key));
};
window.addEventListener('storage', handleChange);
return () => {
window.removeEventListener('storage', handleChange);
};
};
const localStorageAtom = atom<T, [T], undefined>(
(get) => get(baseAtom),
(get, set, newValue) => {
set(baseAtom, newValue);
setItem(key, newValue);
}
);
return localStorageAtom;
};

View file

@ -0,0 +1,9 @@
import { style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
export const BackgroundDotPattern = style({
backgroundImage: `radial-gradient(${color.Background.ContainerActive} ${toRem(2)}, ${
color.Background.Container
} ${toRem(2)})`,
backgroundSize: `${toRem(40)} ${toRem(40)}`,
});

View file

@ -44,6 +44,17 @@ export const fulfilledPromiseSettledResult = <T>(prs: PromiseSettledResult<T>[])
return values;
}, []);
export const promiseFulfilledResult = <T>(
settledResult: PromiseSettledResult<T>
): T | undefined => {
if (settledResult.status === 'fulfilled') return settledResult.value;
return undefined;
};
export const promiseRejectedResult = <T>(settledResult: PromiseSettledResult<T>): any => {
if (settledResult.status === 'rejected') return settledResult.reason;
return undefined;
};
export const binarySearch = <T>(items: T[], match: (item: T) => -1 | 0 | 1): T | undefined => {
const search = (start: number, end: number): T | undefined => {
if (start > end) return undefined;
@ -77,3 +88,10 @@ export const parseGeoUri = (location: string) => {
longitude,
};
};
const START_SLASHES_REG = /^\/+/g;
const END_SLASHES_REG = /\/+$/g;
export const trimLeadingSlash = (str: string): string => str.replace(START_SLASHES_REG, '');
export const trimTrailingSlash = (str: string): string => str.replace(END_SLASHES_REG, '');
export const trimSlash = (str: string): string => trimLeadingSlash(trimTrailingSlash(str));

View file

@ -0,0 +1,84 @@
import { AuthType, IAuthData, UIAFlow } from 'matrix-js-sdk';
export const getSupportedUIAFlows = (uiaFlows: UIAFlow[], supportedStages: string[]): UIAFlow[] => {
const supportedUIAFlows = uiaFlows.filter((flow) =>
flow.stages.every((stage) => supportedStages.includes(stage))
);
return supportedUIAFlows;
};
export const getUIACompleted = (authData: IAuthData): string[] => {
const completed = authData.completed ?? [];
return completed;
};
export type UIAParams = Record<string, Record<string, unknown>>;
export const getUIAParams = (authData: IAuthData): UIAParams => {
const params = authData.params ?? {};
return params;
};
export const getUIASession = (authData: IAuthData): string | undefined => {
const session = authData.session ?? undefined;
return session;
};
export const getUIAErrorCode = (authData: IAuthData): string | undefined => {
const errorCode =
'errcode' in authData && typeof authData.errcode === 'string' ? authData.errcode : undefined;
return errorCode;
};
export const getUIAError = (authData: IAuthData): string | undefined => {
const errorCode =
'error' in authData && typeof authData.error === 'string' ? authData.error : undefined;
return errorCode;
};
export const getUIAFlowForStages = (uiaFlows: UIAFlow[], stages: string[]): UIAFlow | undefined => {
const matchedFlows = uiaFlows
.filter((flow) => {
if (flow.stages.length < stages.length) return false;
if (flow.stages.length > stages.length) {
// As a valid flow can also have m.login.dummy type,
// we will pick one extra length flow only if it has dummy
if (flow.stages.length > stages.length + 1) return false;
if (stages.includes(AuthType.Dummy)) return false;
if (flow.stages.includes(AuthType.Dummy)) return true;
return false;
}
return true;
})
.filter((flow) => stages.every((stage) => flow.stages.includes(stage)));
if (matchedFlows.length === 0) return undefined;
matchedFlows.sort((a, b) => a.stages.length - b.stages.length);
return matchedFlows[0];
};
export const hasStageInFlows = (uiaFlows: UIAFlow[], stage: string) =>
uiaFlows.some((flow) => flow.stages.includes(stage));
export const requiredStageInFlows = (uiaFlows: UIAFlow[], stage: string) =>
uiaFlows.every((flow) => flow.stages.includes(stage));
export const getLoginTermUrl = (params: UIAParams): string | undefined => {
const terms = params[AuthType.Terms];
if (terms && 'policies' in terms && typeof terms.policies === 'object') {
if (terms.policies === null) return undefined;
if ('privacy_policy' in terms.policies && typeof terms.policies.privacy_policy === 'object') {
if (terms.policies.privacy_policy === null) return undefined;
const langToPolicy = terms.policies.privacy_policy as Record<string, any>;
const url = langToPolicy.en?.url;
if (typeof url === 'string') return url;
const firstKey = Object.keys(langToPolicy)[0];
return langToPolicy[firstKey]?.url;
}
}
return undefined;
};

View file

@ -1,5 +1,8 @@
export const HTTP_URL_PATTERN = `https?:\\/\\/(?:www\\.)?(?:[^\\s)]*)(?<![.,:;!/?()[\\]\\s]+)`;
export const EMAIL_REGEX =
/^(([^<>()[\]\\.,;:\s@\\"]+(\.[^<>()[\]\\.,;:\s@\\"]+)*)|(\\".+\\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
export const URL_NEG_LB = '(?<!(https?|ftp|mailto|magnet):\\/\\/\\S*)';
// https://en.wikipedia.org/wiki/Variation_Selectors_(Unicode_block)

View file

@ -98,7 +98,7 @@ async function completeRegisterStage(
}
export {
createTemporaryClient, login, verifyEmail,
updateLocalStore, createTemporaryClient, login, verifyEmail,
loginWithToken, startSsoLogin,
completeRegisterStage,
};

View file

@ -3,7 +3,7 @@ import * as sdk from 'matrix-js-sdk';
import Olm from '@matrix-org/olm';
// import { logger } from 'matrix-js-sdk/lib/logger';
import { secret } from './state/auth';
import { getSecret } from './state/auth';
import RoomList from './state/RoomList';
import AccountData from './state/AccountData';
import RoomsInput from './state/RoomsInput';
@ -40,6 +40,7 @@ class InitMatrix extends EventEmitter {
dbName: 'web-sync-store',
});
await indexedDBStore.startup();
const secret = getSecret();
this.matrixClient = sdk.createClient({
baseUrl: secret.baseUrl,

View file

@ -1,19 +0,0 @@
import cons from './cons';
function getSecret(key) {
return localStorage.getItem(key);
}
const isAuthenticated = () => getSecret(cons.secretKey.ACCESS_TOKEN) !== null;
const secret = {
accessToken: getSecret(cons.secretKey.ACCESS_TOKEN),
deviceId: getSecret(cons.secretKey.DEVICE_ID),
userId: getSecret(cons.secretKey.USER_ID),
baseUrl: getSecret(cons.secretKey.BASE_URL),
};
export {
isAuthenticated,
secret,
};

12
src/client/state/auth.ts Normal file
View file

@ -0,0 +1,12 @@
import cons from './cons';
const isAuthenticated = () => localStorage.getItem(cons.secretKey.ACCESS_TOKEN) !== null;
const getSecret = () => ({
accessToken: localStorage.getItem(cons.secretKey.ACCESS_TOKEN),
deviceId: localStorage.getItem(cons.secretKey.DEVICE_ID),
userId: localStorage.getItem(cons.secretKey.USER_ID),
baseUrl: localStorage.getItem(cons.secretKey.BASE_URL),
});
export { isAuthenticated, getSecret };

7
src/ext.d.ts vendored
View file

@ -1,3 +1,5 @@
/// <reference types="vite/client" />
declare module 'browser-encrypt-attachment' {
export interface EncryptedAttachmentInfo {
v: string;
@ -26,3 +28,8 @@ declare module 'browser-encrypt-attachment' {
info: EncryptedAttachmentInfo
): Promise<ArrayBuffer>;
}
declare module '*.svg' {
const content: string;
export default content;
}

View file

@ -1,6 +1,6 @@
/* eslint-disable import/first */
import React from 'react';
import ReactDom from 'react-dom';
import { createRoot } from 'react-dom/client';
import { enableMapSet } from 'immer';
import '@fontsource/inter/variable.css';
import 'folds/dist/style.css';
@ -15,7 +15,18 @@ import settings from './client/state/settings';
import App from './app/pages/App';
document.body.classList.add(configClass, varsClass);
settings.applyTheme();
ReactDom.render(<App />, document.getElementById('root'));
const mountApp = () => {
const rootContainer = document.getElementById('root');
if (rootContainer === null) {
console.error('Root container element not found!');
return;
}
const root = createRoot(rootContainer);
root.render(<App />);
};
mountApp();

Some files were not shown because too many files have changed in this diff Show more