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
+3
View File
@@ -11,13 +11,16 @@ type AuthContextValue = {
register: (email: string, password: string) => Promise<void>;
user: undefined | User;
};
type User
= paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"];
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
function useAuth() {
const context = use(AuthContext);
if (!context) throw new Error("AuthProvider is required");
return context;
}
@@ -12,6 +12,7 @@ const MAX_PASSWORD_BYTES = 72;
function isEmailCharacterInvalid(character: string): boolean {
const codePoint = character.codePointAt(0);
return (
/\s/.test(character)
|| (codePoint !== undefined && codePoint < ASCII_SPACE)
@@ -31,6 +32,7 @@ const email = z
&& value.split("").every(element => !isEmailCharacterInvalid(element)),
{ error: validationMessage("auth.validation.invalidEmail") },
);
const password = z
.string()
.refine(
@@ -45,6 +47,7 @@ const loginSchema = z.object({
password,
passwordConfirmation: z.string().optional(),
});
const registerSchema = loginSchema.refine(
value => value.password === value.passwordConfirmation,
{
+34 -5
View File
@@ -17,7 +17,9 @@ const bankCardsKey = apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
params: { query: { includeArchived: "false" } },
}).queryKey;
function createAuthWrapper(queryClient: ReturnType<typeof createTestQueryClient>): FC<PropsWithChildren> {
function createAuthWrapper(
queryClient: ReturnType<typeof createTestQueryClient>,
): FC<PropsWithChildren> {
return props => (
<QueryClientProvider client={queryClient}>
<AuthProvider>{props.children}</AuthProvider>
@@ -27,7 +29,10 @@ function createAuthWrapper(queryClient: ReturnType<typeof createTestQueryClient>
describe("AuthProvider", () => {
it("authorizes requests after restoring the initial session", async () => {
let authorization: null | string = /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null;
let authorization: null | string
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
= null;
server.use(
http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({
@@ -37,11 +42,13 @@ describe("AuthProvider", () => {
),
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
authorization = request.headers.get("Authorization");
return HttpResponse.json(resource([]));
}),
);
const queryClient = createTestQueryClient();
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
const { rerender, result } = renderHook(
() => ({ ...useAuth(), queryClient: useQueryClient() }),
{ wrapper },
@@ -80,6 +87,7 @@ describe("AuthProvider", () => {
server.use(
http.post("*/api/v1/auth/refresh", () => {
refreshCalls += 1;
return refreshCalls === 1
? HttpResponse.json({
accessToken: "restored-token",
@@ -119,15 +127,18 @@ describe("AuthProvider", () => {
),
);
const queryClient = createTestQueryClient();
const statusKey = apiQueryClient.queryOptions(
"get",
"/api/v1/tbank-connection",
).queryKey;
const portfolioKey = apiQueryClient.queryOptions(
"get",
"/api/v1/brokerage-accounts/{accountId}/portfolio",
{ params: { path: { accountId: "account-1" } } },
).queryKey;
queryClient.setQueryData(statusKey, {
connected: true,
connectedAt: "2026-08-14T00:00:00Z",
@@ -157,7 +168,9 @@ describe("AuthProvider", () => {
])(
"serializes late refresh (failure=%s, login=%s)",
async (failure, loginAfterLogout) => {
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
const { promise: pending, resolve: release }
= Promise.withResolvers<void>();
let calls = 0;
const order: string[] = [];
server.use(
@@ -167,6 +180,7 @@ describe("AuthProvider", () => {
await pending;
order.push("refresh-cookie");
}
return failure && calls > 1
? HttpResponse.error()
: HttpResponse.json({
@@ -176,10 +190,12 @@ describe("AuthProvider", () => {
}),
http.post("*/api/v1/auth/logout", () => {
order.push("logout-cookie");
return new HttpResponse(null, { status: 204 });
}),
http.post("*/api/v1/auth/login", () => {
order.push("login-cookie");
return HttpResponse.json({
accessToken: "b",
user: { email: "b@example.com", id: "b" },
@@ -193,11 +209,13 @@ describe("AuthProvider", () => {
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.ready).toBe(true));
const oldRequest = apiClient
.GET("/api/v1/brokerage-accounts")
// eslint-disable-next-line unicorn/prefer-await -- Attach rejection handling before releasing the deliberately pending query.
.catch(() => {});
await waitFor(() => expect(order).toContain("refresh-start"));
let logout!: Promise<void>;
act(() => {
@@ -214,8 +232,14 @@ describe("AuthProvider", () => {
release();
await Promise.all([oldRequest, logout, login]);
});
expect(result.current.user?.id ?? /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null).toBe(
loginAfterLogout ? "b" : /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null,
expect(
result.current.user?.id
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
?? null,
).toBe(
loginAfterLogout
? "b"
: null, // eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
);
expect(order).toEqual([
"refresh-start",
@@ -244,17 +268,20 @@ describe("AuthProvider", () => {
await act(() => result.current.refresh());
expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]);
let release!: (value: string) => void;
const pending = queryClient
.query({
queryFn: () => {
const deferred = Promise.withResolvers<string>();
release = deferred.resolve;
return deferred.promise;
},
queryKey: ["pending-finance"],
})
// eslint-disable-next-line unicorn/prefer-await -- Attach rejection handling before releasing the deliberately pending query.
.catch(() => {});
identity = "b";
await act(() => result.current.refresh());
release("a-data");
@@ -278,6 +305,7 @@ describe("AuthProvider", () => {
);
const queryClient = createTestQueryClient();
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
const { result } = renderHook(
() => ({
auth: useAuth(),
@@ -289,6 +317,7 @@ describe("AuthProvider", () => {
}),
{ wrapper },
);
await waitFor(() =>
expect(result.current.cards.data?.[0].id).toBe("a-card"),
);
+4 -149
View File
@@ -1,157 +1,12 @@
import type { FC, PropsWithChildren } from "react";
import { useQueryClient } from "@tanstack/react-query";
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;
}
import { AuthContext } from "./auth-context";
import { useAuthProviderVm } from "./auth.vm";
const AuthProvider: FC<PropsWithChildren> = (props) => {
const { children } = props;
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 value = useAuthProviderVm();
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(), []);
return (
<AuthContext
value={{
accessToken,
login: (email, password) =>
authenticate("/api/v1/auth/login", email, password),
logout,
ready,
refresh,
register: (email, password) =>
authenticate("/api/v1/auth/register", email, password),
user,
}}
>
{children}
</AuthContext>
);
return <AuthContext value={value}>{props.children}</AuthContext>;
};
export { AuthProvider };
+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 };