mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-14 19:20:28 +03:00
feat: URL navigation in auth (#1603)
* bump to react 18 and install react-router-dom * Upgrade to react 18 root * update vite * add cs api's * convert state/auth to ts * add client config context * add auto discovery context * add spec version context * add auth flow context * add background dot pattern css * add promise utils * init url based routing * update auth route server path as effect * add auth server hook * always use server from discovery info in context * login - WIP * upgrade jotai to v2 * add atom with localStorage util * add multi account sessions atom * add default IGNORE res to auto discovery * add error type in async callback hook * handle password login error * fix async callback hook * allow password login * Show custom server not allowed error in mxId login * add sso login component * add token login * fix hardcoded m.login.password in login func * update server input on url change * Improve sso login labels * update folds * fix async callback batching state update in safari * wrap async callback set state in queueMicrotask * wip * wip - register * arrange auth file structure * add error codes * extract filed error component form password login * add register util function * handle register flow - WIP * update unsupported auth flow method reasons * improve password input styles * Improve UIA flow next stage calculation complete stages can have any order so we will look for first stage which is not in completed * process register UIA flow stages * Extract register UIA stages component * improve register error messages * add focus trap & step count in UIA stages * add reset password path and path utils * add path with origin hook * fix sso redirect url * rename register token query param to token * restyle auth screen header * add reset password component - WIP * add reset password form * add netlify rewrites * fix netlify file indentation * test netlify redirect * fix vite to include netlify toml * add more netlify redirects * add splat to public and assets path * fix vite base name * add option to use hash router in config and remove appVersion * add splash screen component * add client config loading and error screen * fix server picker bug * fix reset password email input type * make auth page small screen responsive * fix typo in reset password screen
This commit is contained in:
parent
bb88eb7154
commit
20db27fa7e
103 changed files with 4772 additions and 543 deletions
28
src/app/pages/auth/AuthFooter.tsx
Normal file
28
src/app/pages/auth/AuthFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
215
src/app/pages/auth/AuthLayout.tsx
Normal file
215
src/app/pages/auth/AuthLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/app/pages/auth/FiledError.tsx
Normal file
13
src/app/pages/auth/FiledError.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
src/app/pages/auth/OrDivider.tsx
Normal file
12
src/app/pages/auth/OrDivider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
src/app/pages/auth/SSOLogin.tsx
Normal file
68
src/app/pages/auth/SSOLogin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
src/app/pages/auth/ServerPicker.tsx
Normal file
140
src/app/pages/auth/ServerPicker.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
4
src/app/pages/auth/index.ts
Normal file
4
src/app/pages/auth/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './AuthLayout';
|
||||
export * from './login';
|
||||
export * from './register';
|
||||
export * from './reset-password';
|
||||
73
src/app/pages/auth/login/Login.tsx
Normal file
73
src/app/pages/auth/login/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
272
src/app/pages/auth/login/PasswordLoginForm.tsx
Normal file
272
src/app/pages/auth/login/PasswordLoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
src/app/pages/auth/login/TokenLogin.tsx
Normal file
94
src/app/pages/auth/login/TokenLogin.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1
src/app/pages/auth/login/index.ts
Normal file
1
src/app/pages/auth/login/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Login';
|
||||
118
src/app/pages/auth/login/loginUtil.ts
Normal file
118
src/app/pages/auth/login/loginUtil.ts
Normal 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]);
|
||||
};
|
||||
420
src/app/pages/auth/register/PasswordRegisterForm.tsx
Normal file
420
src/app/pages/auth/register/PasswordRegisterForm.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
95
src/app/pages/auth/register/Register.tsx
Normal file
95
src/app/pages/auth/register/Register.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/pages/auth/register/index.ts
Normal file
1
src/app/pages/auth/register/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './Register';
|
||||
125
src/app/pages/auth/register/registerUtil.ts
Normal file
125
src/app/pages/auth/register/registerUtil.ts
Normal 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]);
|
||||
};
|
||||
274
src/app/pages/auth/reset-password/PasswordResetForm.tsx
Normal file
274
src/app/pages/auth/reset-password/PasswordResetForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/app/pages/auth/reset-password/ResetPassword.tsx
Normal file
36
src/app/pages/auth/reset-password/ResetPassword.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
src/app/pages/auth/reset-password/index.ts
Normal file
1
src/app/pages/auth/reset-password/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './ResetPassword';
|
||||
23
src/app/pages/auth/reset-password/resetPasswordUtil.ts
Normal file
23
src/app/pages/auth/reset-password/resetPasswordUtil.ts
Normal 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];
|
||||
};
|
||||
53
src/app/pages/auth/styles.css.ts
Normal file
53
src/app/pages/auth/styles.css.ts
Normal 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,
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue