Files
frontend/src/features/auth/model/auth.tsx
T
ksv741 57630102e4 chore: add typed ESLint checks #18
Refs WealthLedger/main#18. Keep Biome; add no Prettier dependencies.
2026-09-28 16:36:40 +03:00

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