refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
+173
View File
@@ -0,0 +1,173 @@
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 [accessToken, setAccessToken] = useState<string | undefined>(undefined);
const accessTokenRef = useRef<string | undefined>(undefined);
const [ready, setReady] = 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);
// Cookie-writing requests share ordering, including across tabs with Web Locks.
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(async () =>
"locks" in navigator
? navigator.locks.request("wealth-ledger-refresh", run)
: 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;
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);
setAccessToken(authentication.accessToken);
},
[queryClient],
);
const clearSession = useCallback(() => {
generationRef.current += 1;
userRef.current = undefined;
setUser(undefined);
setAccessToken(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(() => {
if (refreshPromiseRef.current === pending)
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() {
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 = {
accessToken,
login: (email: string, password: string) =>
authenticate("/api/v1/auth/login", email, password),
logout,
ready,
refresh,
register: (email: string, password: string) =>
authenticate("/api/v1/auth/register", email, password),
user,
};
return authContextValue;
}
export { useAuthProviderVm };