feat: add authorization

This commit is contained in:
2026-08-13 15:03:59 +03:00
parent 1e13adabd3
commit 3448fea140
28 changed files with 4504 additions and 3707 deletions
+34 -29
View File
@@ -1,7 +1,9 @@
import { MantineProvider } from "@mantine/core";
import { cleanup, render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it } from "vitest";
import { AuthProvider } from "../../features/auth";
import {
AccountsPageEmpty,
AccountsPageError,
@@ -12,43 +14,46 @@ import { AppRoutes } from "./AppRoutes";
describe("AppRoutes", () => {
afterEach(cleanup);
function renderRoutes(path: string) {
return render(
<QueryClientProvider client={new QueryClient()}>
<MemoryRouter initialEntries={[path]}>
<MantineProvider theme={theme}>
<AuthProvider>
<AppRoutes />
</AuthProvider>
</MantineProvider>
</MemoryRouter>
</QueryClientProvider>,
);
}
it.each([
["/", "Счета"],
["/", "Вход"],
["/login", "Вход"],
["/register", "Регистрация"],
["/accounts", "Счета"],
["/accounts/demo", "Счет"],
["/settings", "Настройки"],
["/accounts", "Вход"],
["/accounts/demo", "Вход"],
["/settings", "Вход"],
["/unknown", "Страница не найдена"],
])("renders %s with its page title", (path, title) => {
render(
<MemoryRouter initialEntries={[path]}>
<MantineProvider theme={theme}>
<AppRoutes />
</MantineProvider>
</MemoryRouter>,
);
])("renders %s with its page title", async (path, title) => {
renderRoutes(path);
expect(
screen.getByRole("heading", { level: 1, name: title }),
).toBeInTheDocument();
await waitFor(() =>
expect(
screen.getByRole("heading", { level: 1, name: title }),
).toBeInTheDocument(),
);
});
it("renders only the empty state for accounts by default", () => {
render(
<MemoryRouter initialEntries={["/accounts"]}>
<MantineProvider theme={theme}>
<AppRoutes />
</MantineProvider>
</MemoryRouter>,
);
it("redirects anonymous users from accounts", async () => {
renderRoutes("/accounts");
expect(
screen.getByRole("heading", { level: 2, name: "Пока ничего нет" }),
).toBeInTheDocument();
expect(screen.queryByText("Загрузка")).not.toBeInTheDocument();
expect(screen.queryByText("Что-то пошло не так")).not.toBeInTheDocument();
await waitFor(() =>
expect(
screen.getByRole("heading", { level: 1, name: "Вход" }),
).toBeInTheDocument(),
);
});
it.each([
+46 -5
View File
@@ -1,5 +1,5 @@
import { Navigate, Route, Routes } from "react-router";
import { Navigate, Route, Routes, useLocation } from "react-router";
import { useAuth } from "../../features/auth";
import { AccountPage } from "../../pages/account";
import { AccountsPage } from "../../pages/accounts";
import { LoginPage } from "../../pages/login";
@@ -8,13 +8,54 @@ import { RegisterPage } from "../../pages/register";
import { SettingsPage } from "../../pages/settings";
import { AppShell } from "../../shared/ui/AppShell";
function Protected({ children }: { children: React.ReactNode }) {
const { user, ready } = useAuth();
const location = useLocation();
if (!ready) return null;
return user ? (
children
) : (
<Navigate
to="/login"
state={{ returnTo: `${location.pathname}${location.search}` }}
replace
/>
);
}
function Public({ children }: { children: React.ReactNode }) {
const { user, ready } = useAuth();
if (!ready) return null;
return user ? <Navigate to="/accounts" replace /> : children;
}
export function AppRoutes() {
return (
<Routes>
<Route path="/" element={<Navigate to="/accounts" replace />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route element={<AppShell />}>
<Route
path="/login"
element={
<Public>
<LoginPage />
</Public>
}
/>
<Route
path="/register"
element={
<Public>
<RegisterPage />
</Public>
}
/>
<Route
element={
<Protected>
<AppShell />
</Protected>
}
>
<Route path="/accounts" element={<AccountsPage />} />
<Route path="/accounts/:accountId" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} />
+15
View File
@@ -32,6 +32,21 @@ a {
.bottom-nav {
display: none;
}
.auth-page {
align-items: center;
display: flex;
justify-content: center;
min-height: 100dvh;
padding: 1.5rem;
}
.auth-card {
box-shadow: 0 12px 32px rgba(30, 41, 59, 0.1);
width: min(100%, 26rem);
}
.auth-card fieldset {
border: 0;
padding: 0;
}
@media (max-width: 47.99375em) {
.bottom-nav {
background: var(--ft-color-bg-surface);
+4 -2
View File
@@ -1,7 +1,7 @@
import { MantineProvider } from "@mantine/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router";
import { AuthProvider } from "../../features/auth";
import { AppRoutes } from "../routes";
import { theme } from "../theme";
@@ -14,7 +14,9 @@ export function App() {
<MantineProvider theme={theme} forceColorScheme="light">
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<AppRoutes />
<AuthProvider>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
</QueryClientProvider>
</MantineProvider>
+3
View File
@@ -0,0 +1,3 @@
export { AuthProvider, useAuth } from "./model/auth";
export { LoginForm } from "./ui/LoginForm";
export { RegisterForm } from "./ui/RegisterForm";
+123
View File
@@ -0,0 +1,123 @@
import { useQueryClient } from "@tanstack/react-query";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { apiClient } from "../../../shared/api";
import type { paths } from "../../../shared/api/generated/schema";
type User =
paths["/api/v1/auth/me"]["get"]["responses"][200]["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"];
type AuthContextValue = {
user: User | null;
accessToken: string | null;
ready: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
refresh: () => Promise<boolean>;
};
const AuthContext = createContext<AuthContextValue | null>(null);
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 });
if (error) throw error;
return data as Authentication;
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const queryClient = useQueryClient();
const [user, setUser] = useState<User | null>(null);
const [accessToken, setAccessToken] = useState<string | null>(null);
const accessTokenRef = useRef<string | null>(null);
const [ready, setReady] = useState(false);
const refreshPromise = useRef<Promise<boolean> | null>(null);
async function refresh(): Promise<boolean> {
if (refreshPromise.current) return refreshPromise.current;
refreshPromise.current = (async () => {
const run = async () => {
const { data } = await apiClient.POST("/api/v1/auth/refresh");
if (!data) return false;
const authentication = data as Authentication;
setUser(authentication.user);
setAccessToken(authentication.accessToken);
accessTokenRef.current = authentication.accessToken;
return true;
};
return "locks" in navigator
? navigator.locks.request("wealth-ledger-refresh", run)
: run();
})().finally(() => {
refreshPromise.current = null;
});
return refreshPromise.current;
}
useEffect(() => {
void apiClient
.POST("/api/v1/auth/refresh")
.then(({ data }) => {
if (!data) return;
const authentication = data as Authentication;
setUser(authentication.user);
setAccessToken(authentication.accessToken);
})
.finally(() => setReady(true));
}, []);
async function authenticate(
path: "/api/v1/auth/login" | "/api/v1/auth/register",
email: string,
password: string,
) {
const authentication = await request(path, email, password);
setUser(authentication.user);
setAccessToken(authentication.accessToken);
accessTokenRef.current = authentication.accessToken;
}
async function logout() {
try {
await apiClient.POST("/api/v1/auth/logout");
} finally {
setUser(null);
setAccessToken(null);
accessTokenRef.current = null;
queryClient.clear();
}
}
return (
<AuthContext
value={{
user,
accessToken,
ready,
login: (email, password) =>
authenticate("/api/v1/auth/login", email, password),
register: (email, password) =>
authenticate("/api/v1/auth/register", email, password),
logout,
refresh,
}}
>
{children}
</AuthContext>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) throw new Error("AuthProvider is required");
return context;
}
+42
View File
@@ -0,0 +1,42 @@
import { MantineProvider } from "@mantine/core";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import { MemoryRouter } from "react-router";
import { afterEach, describe, expect, it, vi } from "vitest";
import { AuthForm } from "./AuthForm";
describe("AuthForm", () => {
afterEach(cleanup);
it("does not submit registration when password confirmation differs", async () => {
const onSubmit = vi.fn();
render(
<MantineProvider>
<MemoryRouter>
<AuthForm register onSubmit={onSubmit} />
</MemoryRouter>
</MantineProvider>,
);
fireEvent.change(screen.getByLabelText("Email"), {
target: { value: "user@example.com" },
});
fireEvent.change(screen.getByLabelText("Пароль"), {
target: { value: "a secure password" },
});
fireEvent.change(screen.getByLabelText("Подтвердите пароль"), {
target: { value: "another secure password" },
});
fireEvent.click(screen.getByRole("button", { name: "Регистрация" }));
await waitFor(() => expect(onSubmit).not.toHaveBeenCalled());
await waitFor(() =>
expect(screen.getByText("Пароли не совпадают")).toBeInTheDocument(),
);
});
});
+117
View File
@@ -0,0 +1,117 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { Alert, Button, PasswordInput, Stack, TextInput } from "@mantine/core";
import { useState } from "react";
import { FormProvider, type Resolver, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
import { z } from "zod";
import { validationMessage } from "../../../shared/i18n";
function isEmailCharacterInvalid(character: string): boolean {
return (
/\s/.test(character) ||
character.charCodeAt(0) < 32 ||
character.charCodeAt(0) === 127
);
}
const email = z
.string()
.trim()
.toLowerCase()
.max(254)
.refine(
(value) =>
value.split("@").length === 2 &&
value.split("@").every(Boolean) &&
!value.split("").some(isEmailCharacterInvalid),
validationMessage("auth.validation.invalidEmail"),
);
const password = z
.string()
.refine(
(value) =>
new TextEncoder().encode(value).length >= 12 &&
new TextEncoder().encode(value).length <= 72,
validationMessage("auth.validation.invalidPasswordLength"),
);
const loginSchema = z.object({
email,
password,
passwordConfirmation: z.string().optional(),
});
const registerSchema = loginSchema.refine(
(value) => value.password === value.passwordConfirmation,
(value) => ({
path: ["passwordConfirmation"],
...validationMessage("auth.validation.passwordsDoNotMatch")(value),
}),
);
type AuthFormValues = z.input<typeof loginSchema>;
export function AuthForm({
register,
onSubmit,
}: {
register?: true;
onSubmit: (email: string, password: string) => Promise<void>;
}) {
const { t } = useTranslation();
const schema = register ? registerSchema : loginSchema;
const methods = useForm<AuthFormValues>({
resolver: zodResolver(schema) as Resolver<AuthFormValues>,
});
const {
formState: { errors, isSubmitting },
} = methods;
const [submitError, setSubmitError] = useState<string | null>(null);
return (
<FormProvider {...methods}>
<form
onSubmit={methods.handleSubmit(async (values) => {
setSubmitError(null);
try {
await onSubmit(values.email, values.password);
} catch {
setSubmitError(t("auth.requestError"));
}
})}
>
<fieldset disabled={isSubmitting}>
<Stack>
<TextInput
label={t("auth.email")}
{...methods.register("email")}
error={errors.email?.message as string}
/>
<PasswordInput
label={t("auth.password")}
{...methods.register("password")}
error={errors.password?.message as string}
/>
{register && (
<PasswordInput
label={t("auth.passwordConfirmation")}
{...methods.register("passwordConfirmation")}
error={errors.passwordConfirmation?.message as string}
/>
)}
{submitError && <Alert color="red">{submitError}</Alert>}
<Button type="submit" loading={isSubmitting}>
{t(register ? "auth.register" : "auth.login")}
</Button>
<Button
component={Link}
to={register ? "/login" : "/register"}
variant="subtle"
>
{t(register ? "auth.haveAccount" : "auth.noAccount")}
</Button>
</Stack>
</fieldset>
</form>
</FormProvider>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../model/auth";
import { AuthForm } from "./AuthForm";
export function LoginForm() {
const { login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const returnTo =
(location.state as { returnTo?: string } | null)?.returnTo ?? "/accounts";
return (
<AuthForm
onSubmit={async (email, password) => {
await login(email, password);
navigate(returnTo, { replace: true });
}}
/>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useLocation, useNavigate } from "react-router";
import { useAuth } from "../model/auth";
import { AuthForm } from "./AuthForm";
export function RegisterForm() {
const { register } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const returnTo =
(location.state as { returnTo?: string } | null)?.returnTo ?? "/accounts";
return (
<AuthForm
register
onSubmit={async (email, password) => {
await register(email, password);
navigate(returnTo, { replace: true });
}}
/>
);
}
+10 -5
View File
@@ -1,14 +1,19 @@
import { Stack, Title } from "@mantine/core";
import { Paper, Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { LoginForm } from "../../../features/auth";
import { StateView } from "../../../shared/ui/StateView";
export function LoginPage() {
const { t } = useTranslation();
return (
<Stack>
<Title order={1}>{t("auth.login")}</Title>
<LoginPageEmpty />
</Stack>
<main className="auth-page">
<Paper className="auth-card" p="xl" radius="lg" withBorder>
<Stack gap="lg">
<Title order={1}>{t("auth.login")}</Title>
<LoginForm />
</Stack>
</Paper>
</main>
);
}
+10 -5
View File
@@ -1,14 +1,19 @@
import { Stack, Title } from "@mantine/core";
import { Paper, Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { RegisterForm } from "../../../features/auth";
import { StateView } from "../../../shared/ui/StateView";
export function RegisterPage() {
const { t } = useTranslation();
return (
<Stack>
<Title order={1}>{t("auth.register")}</Title>
<RegisterPageEmpty />
</Stack>
<main className="auth-page">
<Paper className="auth-card" p="xl" radius="lg" withBorder>
<Stack gap="lg">
<Title order={1}>{t("auth.register")}</Title>
<RegisterForm />
</Stack>
</Paper>
</main>
);
}
+13 -2
View File
@@ -1,13 +1,24 @@
import { Stack, Title } from "@mantine/core";
import { Button, Stack, Title } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import { useAuth } from "../../../features/auth";
import { StateView } from "../../../shared/ui/StateView";
export function SettingsPage() {
const { t } = useTranslation();
const { logout } = useAuth();
const navigate = useNavigate();
return (
<Stack>
<Title order={1}>{t("settings.title")}</Title>
<SettingsPageEmpty />
<Button
onClick={async () => {
await logout();
navigate("/login", { replace: true });
}}
>
{t("auth.logout")}
</Button>
</Stack>
);
}
+4 -1
View File
@@ -3,4 +3,7 @@ import createClient from "openapi-fetch";
import { apiBaseUrl } from "./config";
import type { paths } from "./generated/schema";
export const apiClient = createClient<paths>({ baseUrl: apiBaseUrl });
export const apiClient = createClient<paths>({
baseUrl: apiBaseUrl,
credentials: "include",
});
+157 -14
View File
@@ -4,14 +4,78 @@
*/
export interface paths {
"/api/v1": {
"/api/v1/auth/register": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["AppController_getHello"];
get?: never;
put?: never;
post: operations["AuthController_register"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/auth/login": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["AuthController_login"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/auth/refresh": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["AuthController_refresh"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/auth/logout": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["AuthController_logout"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/auth/me": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["AuthController_me"];
put?: never;
post?: never;
delete?: never;
@@ -24,13 +88,19 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
SafeErrorResponseDto: {
/** @example 500 */
statusCode: number;
/** @example Internal server error */
message: string;
/** @example /api/v1 */
path: string;
CredentialsDto: {
/** @example user@example.com */
email: string;
/** @example a secure password */
password: string;
};
UserDto: {
id: string;
email: string;
};
AuthenticationDto: {
accessToken: string;
user: components["schemas"]["UserDto"];
};
};
responses: never;
@@ -41,7 +111,53 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
AppController_getHello: {
AuthController_register: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CredentialsDto"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AuthenticationDto"];
};
};
};
};
AuthController_login: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CredentialsDto"];
};
};
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AuthenticationDto"];
};
};
};
};
AuthController_refresh: {
parameters: {
query?: never;
header?: never;
@@ -55,16 +171,43 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": string;
"application/json": components["schemas"]["AuthenticationDto"];
};
};
/** @description Internal server error */
500: {
};
};
AuthController_logout: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
AuthController_me: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SafeErrorResponseDto"];
"application/json": components["schemas"]["UserDto"];
};
};
};
+1
View File
@@ -10,4 +10,5 @@ void i18n.use(initReactI18next).init({
interpolation: { escapeValue: false },
});
export { validationMessage } from "./zod";
export { i18n };
+16 -1
View File
@@ -2,7 +2,22 @@ export const ru = {
translation: {
appName: "WealthLedger",
navigation: { accounts: "Счета", settings: "Настройки" },
auth: { login: "Вход", register: "Регистрация" },
auth: {
login: "Вход",
register: "Регистрация",
email: "Email",
password: "Пароль",
passwordConfirmation: "Подтвердите пароль",
noAccount: "Нет аккаунта? Зарегистрироваться",
haveAccount: "Уже есть аккаунт? Войти",
requestError: "Не удалось выполнить запрос. Попробуйте ещё раз.",
logout: "Выйти",
validation: {
invalidEmail: "Некорректный email",
invalidPasswordLength: "Пароль должен содержать от 12 до 72 байт",
passwordsDoNotMatch: "Пароли не совпадают",
},
},
accounts: { title: "Счета" },
account: { title: "Счет" },
settings: { title: "Настройки" },
+9
View File
@@ -0,0 +1,9 @@
import type { ParseKeys } from "i18next";
import { i18n } from ".";
export function validationMessage(
key: ParseKeys,
): (_value: unknown) => { message: string } {
return () => ({ message: i18n.t(key) });
}