151 lines
4.6 KiB
TypeScript
151 lines
4.6 KiB
TypeScript
import { useQueryClient } from "@tanstack/react-query";
|
|
import type { FC, PropsWithChildren } from "react";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { apiClient, configureAuthClient, type paths } from "@/shared/api";
|
|
import { AuthContext, type User } from "./auth-context";
|
|
|
|
type Authentication =
|
|
paths["/api/v1/auth/login"]["post"]["responses"][200]["content"]["application/json"];
|
|
type Credentials =
|
|
paths["/api/v1/auth/login"]["post"]["requestBody"]["content"]["application/json"];
|
|
async function request(
|
|
path: "/api/v1/auth/login" | "/api/v1/auth/register",
|
|
email: string,
|
|
password: string,
|
|
): Promise<Authentication> {
|
|
const { data, error } = await apiClient.POST(path, {
|
|
body: { email, password },
|
|
} satisfies { body: Credentials });
|
|
// eslint-disable-next-line @typescript-eslint/only-throw-error -- Preserve generated ProblemDetails for apiErrorCode and field-error consumers.
|
|
if (error) throw error;
|
|
return data;
|
|
}
|
|
|
|
const AuthProvider: FC<PropsWithChildren> = (props) => {
|
|
const { children } = props;
|
|
const queryClient = useQueryClient();
|
|
const [user, setUser] = useState<User | null>(null);
|
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
|
const accessTokenRef = useRef<string | null>(null);
|
|
const [ready, setReady] = useState(false);
|
|
|
|
const generation = useRef(0);
|
|
const userRef = useRef<User | null>(null);
|
|
const authQueue = useRef<Promise<unknown>>(Promise.resolve());
|
|
const refreshPromise = useRef<Promise<boolean> | null>(null);
|
|
// Cookie-writing requests share ordering, including across tabs with Web Locks.
|
|
const serialize = useCallback(<T,>(run: () => Promise<T>): Promise<T> => {
|
|
const next = authQueue.current.then(async () =>
|
|
"locks" in navigator
|
|
? navigator.locks.request("wealth-ledger-refresh", run)
|
|
: run(),
|
|
);
|
|
authQueue.current = next.catch(() => undefined);
|
|
return next;
|
|
}, []);
|
|
const applyAuthentication = useCallback(
|
|
(authentication: Authentication) => {
|
|
const changedIdentity =
|
|
userRef.current && userRef.current.id !== authentication.user.id;
|
|
accessTokenRef.current = authentication.accessToken;
|
|
if (changedIdentity) {
|
|
generation.current += 1;
|
|
queryClient.removeQueries({ type: "inactive" });
|
|
queryClient.getMutationCache().clear();
|
|
void queryClient.resetQueries();
|
|
}
|
|
userRef.current = authentication.user;
|
|
setUser(authentication.user);
|
|
setAccessToken(authentication.accessToken);
|
|
},
|
|
[queryClient],
|
|
);
|
|
const clearSession = useCallback(() => {
|
|
generation.current += 1;
|
|
userRef.current = null;
|
|
setUser(null);
|
|
setAccessToken(null);
|
|
accessTokenRef.current = null;
|
|
queryClient.clear();
|
|
}, [queryClient]);
|
|
|
|
const refresh = useCallback((): Promise<boolean> => {
|
|
if (refreshPromise.current) return refreshPromise.current;
|
|
const started = generation.current;
|
|
const pending = serialize(async () => {
|
|
if (started !== generation.current) return false;
|
|
try {
|
|
const { data } = await apiClient.POST("/api/v1/auth/refresh");
|
|
if (started !== generation.current) return false;
|
|
if (!data) {
|
|
clearSession();
|
|
return false;
|
|
}
|
|
applyAuthentication(data);
|
|
return true;
|
|
} catch {
|
|
if (started === generation.current) clearSession();
|
|
return false;
|
|
}
|
|
}).finally(() => {
|
|
if (refreshPromise.current === pending) refreshPromise.current = null;
|
|
});
|
|
refreshPromise.current = pending;
|
|
return pending;
|
|
}, [applyAuthentication, clearSession, serialize]);
|
|
|
|
useEffect(() => {
|
|
void refresh().finally(() => setReady(true));
|
|
}, [refresh]);
|
|
|
|
async function authenticate(
|
|
path: "/api/v1/auth/login" | "/api/v1/auth/register",
|
|
email: string,
|
|
password: string,
|
|
) {
|
|
clearSession();
|
|
const started = generation.current;
|
|
await serialize(async () => {
|
|
const authentication = await request(path, email, password);
|
|
if (started === generation.current) applyAuthentication(authentication);
|
|
});
|
|
}
|
|
|
|
async function logout() {
|
|
clearSession();
|
|
await serialize(async () => {
|
|
await apiClient.POST("/api/v1/auth/logout");
|
|
});
|
|
}
|
|
|
|
useEffect(() => {
|
|
configureAuthClient({
|
|
accessToken: () => accessTokenRef.current,
|
|
generation: () => generation.current,
|
|
refresh,
|
|
clear: clearSession,
|
|
});
|
|
});
|
|
useEffect(() => () => configureAuthClient(null), []);
|
|
|
|
return (
|
|
<AuthContext
|
|
value={{
|
|
user,
|
|
accessToken,
|
|
ready,
|
|
login: (email, password) =>
|
|
authenticate("/api/v1/auth/login", email, password),
|
|
register: (email, password) =>
|
|
authenticate("/api/v1/auth/register", email, password),
|
|
logout,
|
|
refresh,
|
|
}}
|
|
>
|
|
{children}
|
|
</AuthContext>
|
|
);
|
|
};
|
|
|
|
export { AuthProvider };
|