Files
frontend/src/features/auth/model/auth.vm.ts
T
ksv741 19fe99695c
Frontend verify / commits (pull_request) Successful in 37s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 5m51s
fix: stabilize auth client registration (WealthLedger/frontend#95)
2026-10-28 16:34:30 +03:00

185 lines
5.5 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";
// A 5xx or network error means the server is unreachable, not that the session ended.
const HTTP_SERVER_ERROR = 500;
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 [unavailable, setUnavailable] = useState(false);
const unavailableRef = useRef(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 markUnavailable = useCallback((isUnavailable: boolean) => {
unavailableRef.current = isUnavailable;
setUnavailable(isUnavailable);
}, []);
const applyAuthentication = useCallback(
(authentication: Authentication) => {
const changedIdentity
= userRef.current && userRef.current.id !== authentication.user.id;
setSignedOut(false);
markUnavailable(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);
},
[markUnavailable, queryClient],
);
const clearSession = useCallback(() => {
markUnavailable(false);
generationRef.current += 1;
userRef.current = undefined;
setUser(undefined);
accessTokenRef.current = undefined;
queryClient.clear();
}, [markUnavailable, 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, response } = await apiClient.POST("/api/v1/auth/refresh");
if (started !== generationRef.current) return false;
if (!data) {
if (response.status >= HTTP_SERVER_ERROR) markUnavailable(true);
else clearSession();
return false;
}
applyAuthentication(data);
return true;
}
catch {
if (started === generationRef.current) markUnavailable(true);
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, markUnavailable, 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,
unavailable: () => unavailableRef.current,
});
// `user` tracks access-token changes, which configureAuthClient publishes as session state.
}, [clearSession, refresh, user]);
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),
retry: () => void refresh(),
signedOut,
unavailable,
user,
};
return authContextValue;
}
export { useAuthProviderVm };