refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user