chore: finish frontend ESLint migration #18

Enable every plugin recommended preset and replace Biome with ESLint formatting.

Preserve API contracts and JSX whitespace; verify source, tests, builds and tooling.
This commit is contained in:
2026-09-28 16:36:40 +03:00
committed by ksv741
parent 57630102e4
commit 72d7835227
253 changed files with 10059 additions and 8864 deletions
+11 -10
View File
@@ -1,23 +1,24 @@
import { createContext, useContext } from "react";
import { createContext, use } from "react";
import type { paths } from "@/shared/api";
type User =
paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"];
type AuthContextValue = {
user: User | null;
accessToken: string | null;
ready: boolean;
accessToken: string | undefined;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
ready: boolean;
refresh: () => Promise<boolean>;
register: (email: string, password: string) => Promise<void>;
user: undefined | User;
};
const AuthContext = createContext<AuthContextValue | null>(null);
type User
= paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"];
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
function useAuth() {
const context = useContext(AuthContext);
const context = use(AuthContext);
if (!context) throw new Error("AuthProvider is required");
return context;
}
export { AuthContext, type User, useAuth };
export { AuthContext, useAuth, type User };
+50 -81
View File
@@ -1,12 +1,15 @@
import type { FC, PropsWithChildren } from "react";
import { QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import { HttpResponse, http } from "msw";
import type { FC, PropsWithChildren } from "react";
import { http, HttpResponse } from "msw";
import { describe, expect, it } from "vitest";
import { apiClient, apiQueryClient, useAuthenticatedQuery } from "@/shared/api";
import { createTestQueryClient } from "@/test/query";
import { resource } from "@/test/resource";
import { server } from "@/test/server";
import { AuthProvider } from "./auth";
import { useAuth } from "./auth-context";
@@ -14,14 +17,22 @@ const bankCardsKey = apiQueryClient.queryOptions("get", "/api/v1/bank-cards", {
params: { query: { includeArchived: "false" } },
}).queryKey;
function createAuthWrapper(queryClient: ReturnType<typeof createTestQueryClient>): FC<PropsWithChildren> {
return props => (
<QueryClientProvider client={queryClient}>
<AuthProvider>{props.children}</AuthProvider>
</QueryClientProvider>
);
}
describe("AuthProvider", () => {
it("authorizes requests after restoring the initial session", async () => {
let authorization: string | null = null;
let authorization: null | string = /* eslint-disable-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({
accessToken: "restored-token",
user: { id: "user-1", email: "user@example.com" },
user: { email: "user@example.com", id: "user-1" },
}),
),
http.get("*/api/v1/brokerage-accounts", ({ request }) => {
@@ -30,16 +41,8 @@ describe("AuthProvider", () => {
}),
);
const queryClient = createTestQueryClient();
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
const { result, rerender } = renderHook(
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
const { rerender, result } = renderHook(
() => ({ ...useAuth(), queryClient: useQueryClient() }),
{ wrapper },
);
@@ -61,21 +64,13 @@ describe("AuthProvider", () => {
const queryClient = createTestQueryClient();
queryClient.setQueryData(["cached-query"], { value: "stale" });
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.ready).toBe(true));
expect(result.current.user).toBeNull();
expect(result.current.accessToken).toBeNull();
expect(result.current.user).toBeUndefined();
expect(result.current.accessToken).toBeUndefined();
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
});
@@ -88,7 +83,7 @@ describe("AuthProvider", () => {
return refreshCalls === 1
? HttpResponse.json({
accessToken: "restored-token",
user: { id: "user-1", email: "user@example.com" },
user: { email: "user@example.com", id: "user-1" },
})
: HttpResponse.error();
}),
@@ -99,15 +94,7 @@ describe("AuthProvider", () => {
const queryClient = createTestQueryClient();
queryClient.setQueryData(["cached-query"], { value: "stale" });
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() =>
@@ -116,8 +103,8 @@ describe("AuthProvider", () => {
const response = await apiClient.GET("/api/v1/brokerage-accounts");
expect(response.response.status).toBe(401);
await waitFor(() => expect(result.current.user).toBeNull());
expect(result.current.accessToken).toBeNull();
await waitFor(() => expect(result.current.user).toBeUndefined());
expect(result.current.accessToken).toBeUndefined();
expect(queryClient.getQueryData(["cached-query"])).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
});
@@ -127,7 +114,7 @@ describe("AuthProvider", () => {
http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({
accessToken: "test-token",
user: { id: "user-1", email: "user@example.com" },
user: { email: "user@example.com", id: "user-1" },
}),
),
);
@@ -147,14 +134,7 @@ describe("AuthProvider", () => {
});
queryClient.setQueryData(portfolioKey, { source: "current-connection" });
queryClient.setQueryData(bankCardsKey, [{ id: "bank-card-1" }]);
const wrapper: FC<PropsWithChildren> = (props) => {
const { children } = props;
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
};
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.accessToken).toBe("test-token"));
@@ -163,8 +143,8 @@ describe("AuthProvider", () => {
]);
await act(() => result.current.logout());
expect(result.current.user).toBeNull();
expect(result.current.accessToken).toBeNull();
expect(result.current.user).toBeUndefined();
expect(result.current.accessToken).toBeUndefined();
expect(queryClient.getQueryData(statusKey)).toBeUndefined();
expect(queryClient.getQueryData(portfolioKey)).toBeUndefined();
expect(queryClient.getQueryData(bankCardsKey)).toBeUndefined();
@@ -177,10 +157,7 @@ describe("AuthProvider", () => {
])(
"serializes late refresh (failure=%s, login=%s)",
async (failure, loginAfterLogout) => {
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
const { promise: pending, resolve: release } = Promise.withResolvers<void>();
let calls = 0;
const order: string[] = [];
server.use(
@@ -194,7 +171,7 @@ describe("AuthProvider", () => {
? HttpResponse.error()
: HttpResponse.json({
accessToken: "a",
user: { id: "a", email: "a@example.com" },
user: { email: "a@example.com", id: "a" },
});
}),
http.post("*/api/v1/auth/logout", () => {
@@ -205,7 +182,7 @@ describe("AuthProvider", () => {
order.push("login-cookie");
return HttpResponse.json({
accessToken: "b",
user: { id: "b", email: "b@example.com" },
user: { email: "b@example.com", id: "b" },
});
}),
http.get("*/api/v1/brokerage-accounts", () =>
@@ -213,22 +190,20 @@ describe("AuthProvider", () => {
),
);
const queryClient = createTestQueryClient();
const wrapper: FC<PropsWithChildren> = ({ children }) => (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
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")
.catch(() => null);
// 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(() => {
logout = result.current.logout();
});
await waitFor(() => expect(result.current.user).toBeNull());
await waitFor(() => expect(result.current.user).toBeUndefined());
let login!: Promise<void>;
act(() => {
login = loginAfterLogout
@@ -239,8 +214,8 @@ describe("AuthProvider", () => {
release();
await Promise.all([oldRequest, logout, login]);
});
expect(result.current.user?.id ?? null).toBe(
loginAfterLogout ? "b" : null,
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(order).toEqual([
"refresh-start",
@@ -257,16 +232,12 @@ describe("AuthProvider", () => {
http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({
accessToken: identity,
user: { id: identity, email: `${identity}@example.com` },
user: { email: `${identity}@example.com`, id: identity },
}),
),
);
const queryClient = createTestQueryClient();
const wrapper: FC<PropsWithChildren> = ({ children }) => (
<QueryClientProvider client={queryClient}>
<AuthProvider>{children}</AuthProvider>
</QueryClientProvider>
);
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
const { result } = renderHook(() => useAuth(), { wrapper });
await waitFor(() => expect(result.current.ready).toBe(true));
queryClient.setQueryData(bankCardsKey, [{ id: "a-card" }]);
@@ -274,14 +245,16 @@ describe("AuthProvider", () => {
expect(queryClient.getQueryData(bankCardsKey)).toEqual([{ id: "a-card" }]);
let release!: (value: string) => void;
const pending = queryClient
.fetchQuery({
.query({
queryFn: () => {
const deferred = Promise.withResolvers<string>();
release = deferred.resolve;
return deferred.promise;
},
queryKey: ["pending-finance"],
queryFn: () =>
new Promise<string>((resolve) => {
release = resolve;
}),
})
.catch(() => null);
// 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");
@@ -296,7 +269,7 @@ describe("AuthProvider", () => {
http.post("*/api/v1/auth/refresh", () =>
HttpResponse.json({
accessToken: identity,
user: { id: identity, email: `${identity}@example.com` },
user: { email: `${identity}@example.com`, id: identity },
}),
),
http.get("*/api/v1/bank-cards", () =>
@@ -304,11 +277,7 @@ describe("AuthProvider", () => {
),
);
const queryClient = createTestQueryClient();
const wrapper: FC<PropsWithChildren> = (props) => (
<QueryClientProvider client={queryClient}>
<AuthProvider>{props.children}</AuthProvider>
</QueryClientProvider>
);
const wrapper: FC<PropsWithChildren> = createAuthWrapper(queryClient);
const { result } = renderHook(
() => ({
auth: useAuth(),
+46 -39
View File
@@ -1,13 +1,16 @@
import { useQueryClient } from "@tanstack/react-query";
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"];
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,
@@ -24,32 +27,34 @@ async function request(
const AuthProvider: FC<PropsWithChildren> = (props) => {
const { children } = props;
const queryClient = useQueryClient();
const [user, setUser] = useState<User | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const accessTokenRef = useRef<string | null>(null);
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 generation = useRef(0);
const userRef = useRef<User | null>(null);
const authQueue = useRef<Promise<unknown>>(Promise.resolve());
const refreshPromise = useRef<Promise<boolean> | null>(null);
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> => {
const next = authQueue.current.then(async () =>
// 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(),
);
authQueue.current = next.catch(() => undefined);
// 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;
const changedIdentity
= userRef.current && userRef.current.id !== authentication.user.id;
accessTokenRef.current = authentication.accessToken;
if (changedIdentity) {
generation.current += 1;
generationRef.current += 1;
queryClient.removeQueries({ type: "inactive" });
queryClient.getMutationCache().clear();
void queryClient.resetQueries();
@@ -61,36 +66,38 @@ const AuthProvider: FC<PropsWithChildren> = (props) => {
[queryClient],
);
const clearSession = useCallback(() => {
generation.current += 1;
userRef.current = null;
setUser(null);
setAccessToken(null);
accessTokenRef.current = null;
generationRef.current += 1;
userRef.current = undefined;
setUser(undefined);
setAccessToken(undefined);
accessTokenRef.current = undefined;
queryClient.clear();
}, [queryClient]);
const refresh = useCallback((): Promise<boolean> => {
if (refreshPromise.current) return refreshPromise.current;
const started = generation.current;
if (refreshPromiseRef.current) return refreshPromiseRef.current;
const started = generationRef.current;
const pending = serialize(async () => {
if (started !== generation.current) return false;
if (started !== generationRef.current) return false;
try {
const { data } = await apiClient.POST("/api/v1/auth/refresh");
if (started !== generation.current) return false;
if (started !== generationRef.current) return false;
if (!data) {
clearSession();
return false;
}
applyAuthentication(data);
return true;
} catch {
if (started === generation.current) clearSession();
}
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 (refreshPromise.current === pending) refreshPromise.current = null;
if (refreshPromiseRef.current === pending) refreshPromiseRef.current = undefined;
});
refreshPromise.current = pending;
refreshPromiseRef.current = pending;
return pending;
}, [applyAuthentication, clearSession, serialize]);
@@ -104,10 +111,10 @@ const AuthProvider: FC<PropsWithChildren> = (props) => {
password: string,
) {
clearSession();
const started = generation.current;
const started = generationRef.current;
await serialize(async () => {
const authentication = await request(path, email, password);
if (started === generation.current) applyAuthentication(authentication);
if (started === generationRef.current) applyAuthentication(authentication);
});
}
@@ -121,25 +128,25 @@ const AuthProvider: FC<PropsWithChildren> = (props) => {
useEffect(() => {
configureAuthClient({
accessToken: () => accessTokenRef.current,
generation: () => generation.current,
refresh,
clear: clearSession,
generation: () => generationRef.current,
refresh,
});
});
useEffect(() => () => configureAuthClient(null), []);
useEffect(() => () => configureAuthClient(), []);
return (
<AuthContext
value={{
user,
accessToken,
ready,
login: (email, password) =>
authenticate("/api/v1/auth/login", email, password),
logout,
ready,
refresh,
register: (email, password) =>
authenticate("/api/v1/auth/register", email, password),
logout,
refresh,
user,
}}
>
{children}
+16 -12
View File
@@ -1,6 +1,9 @@
import { z } from "zod";
import { validationMessage } from "@/shared/i18n";
const EMAIL_PART_COUNT = 2;
const ASCII_SPACE = 32;
const ASCII_DELETE = 127;
const MAX_EMAIL_LENGTH = 254;
@@ -8,10 +11,11 @@ const MIN_PASSWORD_BYTES = 12;
const MAX_PASSWORD_BYTES = 72;
function isEmailCharacterInvalid(character: string): boolean {
const codePoint = character.codePointAt(0);
return (
/\s/.test(character) ||
character.charCodeAt(0) < ASCII_SPACE ||
character.charCodeAt(0) === ASCII_DELETE
/\s/.test(character)
|| (codePoint !== undefined && codePoint < ASCII_SPACE)
|| codePoint === ASCII_DELETE
);
}
@@ -21,18 +25,18 @@ const email = z
.toLowerCase()
.max(MAX_EMAIL_LENGTH)
.refine(
(value) =>
value.split("@").length === 2 &&
value.split("@").every(Boolean) &&
!value.split("").some(isEmailCharacterInvalid),
value =>
value.split("@").length === EMAIL_PART_COUNT
&& value.split("@").every(Boolean)
&& value.split("").every(element => !isEmailCharacterInvalid(element)),
{ error: validationMessage("auth.validation.invalidEmail") },
);
const password = z
.string()
.refine(
(value) =>
new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES &&
new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES,
value =>
new TextEncoder().encode(value).length >= MIN_PASSWORD_BYTES
&& new TextEncoder().encode(value).length <= MAX_PASSWORD_BYTES,
{ error: validationMessage("auth.validation.invalidPasswordLength") },
);
@@ -42,10 +46,10 @@ const loginSchema = z.object({
passwordConfirmation: z.string().optional(),
});
const registerSchema = loginSchema.refine(
(value) => value.password === value.passwordConfirmation,
value => value.password === value.passwordConfirmation,
{
path: ["passwordConfirmation"],
error: validationMessage("auth.validation.passwordsDoNotMatch"),
path: ["passwordConfirmation"],
},
);
@@ -2,6 +2,7 @@ import { MantineProvider } from "@mantine/core";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { describe, expect, it, vi } from "vitest";
import { AuthForm } from "./AuthForm";
describe("AuthForm", () => {
@@ -10,7 +11,7 @@ describe("AuthForm", () => {
render(
<MantineProvider>
<MemoryRouter>
<AuthForm register onSubmit={onSubmit} />
<AuthForm onSubmit={onSubmit} register />
</MemoryRouter>
</MantineProvider>,
);
+9 -6
View File
@@ -1,10 +1,12 @@
import type { FC } from "react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import type { FC } from "react";
import { useState } from "react";
import { FormProvider, type Resolver, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import {
type AuthFormValues,
loginSchema,
@@ -13,12 +15,12 @@ import {
import classes from "./AuthForm.module.css";
type AuthFormProps = {
register?: true;
onSubmit: (email: string, password: string) => Promise<void>;
register?: true;
};
const AuthForm: FC<AuthFormProps> = (props) => {
const { register, onSubmit } = props;
const { onSubmit, register } = props;
const { t } = useTranslation();
const schema = register ? registerSchema : loginSchema;
const methods = useForm<AuthFormValues>({
@@ -27,7 +29,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
const {
formState: { errors, isSubmitting },
} = methods;
const [submitError, setSubmitError] = useState<string | null>(null);
const [submitError, setSubmitError] = useState<null | string>(null);
return (
<FormProvider {...methods}>
<form
@@ -35,7 +37,8 @@ const AuthForm: FC<AuthFormProps> = (props) => {
setSubmitError(null);
try {
await onSubmit(values.email, values.password);
} catch {
}
catch {
setSubmitError(t("auth.requestError"));
}
})}
@@ -60,7 +63,7 @@ const AuthForm: FC<AuthFormProps> = (props) => {
/>
)}
{submitError && <Alert color="red">{submitError}</Alert>}
<Button type="submit" loading={isSubmitting}>
<Button loading={isSubmitting} type="submit">
{t(register ? "auth.register" : "auth.login")}
</Button>
<Button
@@ -1,5 +1,7 @@
import { Paper, Stack, Title } from "@mantine/core";
import type { FC, PropsWithChildren } from "react";
import { Paper, Stack, Title } from "@mantine/core";
import classes from "./AuthPageLayout.module.css";
type AuthPageLayoutProps = PropsWithChildren<{ title: string }>;
+4 -2
View File
@@ -1,5 +1,7 @@
import type { FC } from "react";
import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../model/auth-context";
import { AuthForm } from "./AuthForm/AuthForm";
@@ -7,8 +9,8 @@ const LoginForm: FC = () => {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const returnTo =
(location.state as { returnTo?: string } | null)?.returnTo ?? "/accounts";
const returnTo
= (location.state as null | { returnTo?: string })?.returnTo ?? "/accounts";
return (
<AuthForm
+5 -3
View File
@@ -1,5 +1,7 @@
import type { FC } from "react";
import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../model/auth-context";
import { AuthForm } from "./AuthForm/AuthForm";
@@ -7,16 +9,16 @@ const RegisterForm: FC = () => {
const { register } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const returnTo =
(location.state as { returnTo?: string } | null)?.returnTo ?? "/accounts";
const returnTo
= (location.state as null | { returnTo?: string })?.returnTo ?? "/accounts";
return (
<AuthForm
register
onSubmit={async (email, password) => {
await register(email, password);
await navigate(returnTo, { replace: true });
}}
register
/>
);
};