Files
frontend/src/features/auth/model/auth.vm.ts
T
ksv741 69997075e5
Frontend verify / commits (pull_request) Failing after 27s
Frontend verify / security (pull_request) Skipped
Frontend verify / verify (pull_request) Skipped
refactor!: simplify query and session ownership
Use Query cancellation and native mutation results instead of HTTP
registries, access epochs and copied scope state.
Keep local auth ordering and mutation identity fences.
Allow staged generated files to remain excluded from ESLint.

Backend contract: WealthLedger/backend@d698ed9

BREAKING CHANGE: drop investment stateVersion/progress/resources
from the client contract and use data/metadata readiness instead of
rewritten query status. Cross-tab auth may require re-login.
2026-10-03 11:45:04 +03:00

168 lines
4.8 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { useCallback, useEffect, useRef, useState } from "react";
import { apiClient, configureAuthClient, type paths } from "@/shared/api";
import { 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;
}
function useAuthProviderVm() {
const queryClient = useQueryClient();
const [user, setUser] = useState<undefined | User>(undefined);
const accessTokenRef = useRef<string | undefined>(undefined);
const [ready, setReady] = useState(false);
const [signedOut, setSignedOut] = useState(false);
const generationRef = useRef(0);
const userRef = useRef<undefined | User>(undefined);
const authQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const refreshPromiseRef = useRef<Promise<boolean> | undefined>(undefined);
// Order cookie-writing requests within this tab. Other tabs may reauthenticate.
const serialize = useCallback(<T>(run: () => Promise<T>): Promise<T> => {
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
const next = authQueueRef.current.then(run);
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
authQueueRef.current = next.catch(() => {});
return next;
}, []);
const applyAuthentication = useCallback(
(authentication: Authentication) => {
const changedIdentity
= userRef.current && userRef.current.id !== authentication.user.id;
setSignedOut(false);
accessTokenRef.current = authentication.accessToken;
if (changedIdentity) {
generationRef.current += 1;
queryClient.removeQueries({ type: "inactive" });
queryClient.getMutationCache().clear();
void queryClient.resetQueries();
}
userRef.current = authentication.user;
setUser(authentication.user);
},
[queryClient],
);
const clearSession = useCallback(() => {
generationRef.current += 1;
userRef.current = undefined;
setUser(undefined);
accessTokenRef.current = undefined;
queryClient.clear();
}, [queryClient]);
const refresh = useCallback((): Promise<boolean> => {
if (refreshPromiseRef.current) return refreshPromiseRef.current;
const started = generationRef.current;
const pending = serialize(async () => {
if (started !== generationRef.current) return false;
try {
const { data } = await apiClient.POST("/api/v1/auth/refresh");
if (started !== generationRef.current) return false;
if (!data) {
clearSession();
return false;
}
applyAuthentication(data);
return true;
}
catch {
if (started === generationRef.current) clearSession();
return false;
}
// eslint-disable-next-line unicorn/prefer-await -- Attach the handler immediately and retain the pending promise for queue/race ordering.
}).finally(() => {
refreshPromiseRef.current = undefined;
});
refreshPromiseRef.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 = generationRef.current;
await serialize(async () => {
const authentication = await request(path, email, password);
if (started === generationRef.current)
applyAuthentication(authentication);
});
}
async function logout() {
setSignedOut(true);
clearSession();
await serialize(async () => {
await apiClient.POST("/api/v1/auth/logout");
});
}
useEffect(() => {
configureAuthClient({
accessToken: () => accessTokenRef.current,
clear: clearSession,
generation: () => generationRef.current,
refresh,
});
});
useEffect(() => () => configureAuthClient(), []);
const authContextValue = {
login: (email: string, password: string) =>
authenticate("/api/v1/auth/login", email, password),
logout,
ready,
register: (email: string, password: string) =>
authenticate("/api/v1/auth/register", email, password),
signedOut,
user,
};
return authContextValue;
}
export { useAuthProviderVm };