feat: URL navigation in auth (#1603)

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

* Upgrade to react 18 root

* update vite

* add cs api's

* convert state/auth to ts

* add client config context

* add auto discovery context

* add spec version context

* add auth flow context

* add background dot pattern css

* add promise utils

* init url based routing

* update auth route server path as effect

* add auth server hook

* always use server from discovery info in context

* login - WIP

* upgrade jotai to v2

* add atom with localStorage util

* add multi account sessions atom

* add default IGNORE res to auto discovery

* add error type in async callback hook

* handle password login error

* fix async callback hook

* allow password login

* Show custom server not allowed error in mxId login

* add sso login component

* add token login

* fix hardcoded m.login.password in login func

* update server input on url change

* Improve sso login labels

* update folds

* fix async callback batching state update in safari

* wrap async callback set state in queueMicrotask

* wip

* wip - register

* arrange auth file structure

* add error codes

* extract filed error component form password login

* add register util function

* handle register flow - WIP

* update unsupported auth flow method reasons

* improve password input styles

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

* process register UIA flow stages

* Extract register UIA stages component

* improve register error messages

* add focus trap & step count in UIA stages

* add reset password path and path utils

* add path with origin hook

* fix sso redirect url

* rename register token query param to token

* restyle auth screen header

* add reset password component - WIP

* add reset password form

* add netlify rewrites

* fix netlify file indentation

* test netlify redirect

* fix vite to include netlify toml

* add more netlify redirects

* add splat to public and assets path

* fix vite base name

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

* add splash screen component

* add client config loading and error screen

* fix server picker bug

* fix reset password email input type

* make auth page small screen responsive

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

View file

@ -0,0 +1,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];
};