mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-06 15:30:27 +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
12
src/app/hooks/types.ts
Normal file
12
src/app/hooks/types.ts
Normal 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>;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
59
src/app/hooks/useAuthFlows.ts
Normal file
59
src/app/hooks/useAuthFlows.ts
Normal 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;
|
||||
};
|
||||
14
src/app/hooks/useAuthServer.ts
Normal file
14
src/app/hooks/useAuthServer.ts
Normal 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;
|
||||
};
|
||||
15
src/app/hooks/useAutoDiscoveryInfo.ts
Normal file
15
src/app/hooks/useAutoDiscoveryInfo.ts
Normal 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;
|
||||
};
|
||||
33
src/app/hooks/useClientConfig.ts
Normal file
33
src/app/hooks/useClientConfig.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
38
src/app/hooks/useParsedLoginFlows.ts
Normal file
38
src/app/hooks/useParsedLoginFlows.ts
Normal 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;
|
||||
};
|
||||
32
src/app/hooks/usePasswordEmail.ts
Normal file
32
src/app/hooks/usePasswordEmail.ts
Normal 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];
|
||||
};
|
||||
26
src/app/hooks/usePathWithOrigin.ts
Normal file
26
src/app/hooks/usePathWithOrigin.ts
Normal 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;
|
||||
};
|
||||
32
src/app/hooks/useRegisterEmail.ts
Normal file
32
src/app/hooks/useRegisterEmail.ts
Normal 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];
|
||||
};
|
||||
12
src/app/hooks/useSpecVersions.ts
Normal file
12
src/app/hooks/useSpecVersions.ts
Normal 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;
|
||||
}
|
||||
96
src/app/hooks/useUIAFlows.ts
Normal file
96
src/app/hooks/useUIAFlows.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue