feat: add authorization
This commit is contained in:
@@ -16,10 +16,17 @@ npm run check:no-todo
|
|||||||
|
|
||||||
`npm run openapi:generate` получает документ работающего backend по `${VITE_API_BASE_URL:-http://localhost:3000}/api/docs-json`; фактическая генерация выполняется только в задаче 5.1. `build` выполняет `tsc -b`.
|
`npm run openapi:generate` получает документ работающего backend по `${VITE_API_BASE_URL:-http://localhost:3000}/api/docs-json`; фактическая генерация выполняется только в задаче 5.1. `build` выполняет `tsc -b`.
|
||||||
|
|
||||||
|
## Документация для агента
|
||||||
|
|
||||||
|
- Перед изменением API или transport types прочитать [`docs/API-LAYER.md`](docs/API-LAYER.md).
|
||||||
|
- Перед созданием формы, страницы или UI-состояний прочитать [`docs/UI-PATTERN.md`](docs/UI-PATTERN.md).
|
||||||
|
- Перед изменением структуры слайса или импортов прочитать [`docs/STRUCTURE.md`](docs/STRUCTURE.md).
|
||||||
|
- Перед кодом и тестами прочитать [`docs/CONVENTIONS.md`](docs/CONVENTIONS.md) и [`docs/TESTING.md`](docs/TESTING.md).
|
||||||
|
|
||||||
## Границы
|
## Границы
|
||||||
|
|
||||||
- Текущий baseline использует FSD, React Router, i18n, Mantine, React Query и typed API scaffold.
|
- Текущий baseline использует FSD, React Router, i18n, Mantine, React Query и typed API scaffold.
|
||||||
- Зависимости FSD идут `app -> pages -> features -> entities -> shared`; слайсы импортируются только через публичный `index.ts`.
|
- Зависимости FSD идут `app -> pages -> features -> entities -> shared`; слайсы импортируются только через публичный `index.ts`.
|
||||||
- HTTP идет через типизированный клиент, сгенерированный из OpenAPI; UI-строки - только через i18n. Каждый маршрут имеет loading, error и empty состояния.
|
- HTTP идет только через типизированный клиент; request/response types выводятся из generated OpenAPI `paths[...]`, а не дублируются вручную. UI-строки - только через i18n. Каждый маршрут имеет loading, error и empty состояния.
|
||||||
- Соблюдать strict TypeScript: `import type` для типов, без `enum` и `namespace`.
|
- Соблюдать strict TypeScript: `import type` для типов, без `enum` и `namespace`.
|
||||||
- Не добавлять прямые вызовы T-Bank и не переносить Todo-код из `../references/`.
|
- Не добавлять прямые вызовы T-Bank и не переносить Todo-код из `../references/`.
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# API Layer
|
||||||
|
|
||||||
|
OpenAPI backend является источником истины. После изменения backend-контракта запускается `npm run openapi:generate`; `src/shared/api/generated/schema.d.ts` не редактируется вручную.
|
||||||
|
|
||||||
|
Все HTTP-вызовы используют `apiClient` из `src/shared/api`. Прямой `fetch` в features, pages и entities не используется.
|
||||||
|
|
||||||
|
Request и response types выводятся из generated `paths`, например:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type User = paths["/api/v1/auth/me"]["get"]["responses"][200]["content"]["application/json"];
|
||||||
|
```
|
||||||
|
|
||||||
|
Ручной domain/UI type допустим только если он отличается от transport DTO по смыслу, а не повторяет его поля.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Conventions
|
||||||
|
|
||||||
|
## Must
|
||||||
|
|
||||||
|
- Использовать Biome: `npm run lint`; табы и двойные кавычки определяет `biome.json`.
|
||||||
|
- Для type-only импортов использовать `import type`; не использовать `enum` и `namespace`.
|
||||||
|
- UI-строки хранить в `src/shared/i18n/locales/ru.ts` и получать через `useTranslation()`.
|
||||||
|
- Zod-схемы не содержат пользовательских строк: использовать `validationMessage(key)` из `src/shared/i18n/zod.ts`.
|
||||||
|
- Публичный импорт слайса идёт через его `index.ts`.
|
||||||
|
- Тесты лежат рядом с исходником как `*.test.tsx`.
|
||||||
|
|
||||||
|
## File roles
|
||||||
|
|
||||||
|
- `Component.tsx` - контейнер или публичный компонент.
|
||||||
|
- `Component.vm.ts` - hook с данными и действиями.
|
||||||
|
- `Component.view.tsx` - чистое представление.
|
||||||
|
- `Component.types.ts` - props и локальные типы.
|
||||||
|
- `Component.module.css` - локальные стили.
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Frontend Documentation
|
||||||
|
|
||||||
|
Эти документы задают правила AI-разработки и дополняют `AGENTS.md`. Фактические версии зависимостей и scripts всегда определяются конфигурацией проекта.
|
||||||
|
|
||||||
|
- `CONVENTIONS.md` - обязательные правила кода и i18n.
|
||||||
|
- `API-LAYER.md` - OpenAPI, generated types и typed client.
|
||||||
|
- `UI-PATTERN.md` - формы и UI-состояния.
|
||||||
|
- `STRUCTURE.md` - FSD-слои, импорты и public API слайсов.
|
||||||
|
- `TESTING.md` - Vitest и Testing Library.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Structure
|
||||||
|
|
||||||
|
Проект использует FSD-направление импортов: `app -> pages -> features -> entities -> shared`.
|
||||||
|
|
||||||
|
- `app` - providers, routes и глобальные стили.
|
||||||
|
- `pages` - страницы маршрутов.
|
||||||
|
- `features` - пользовательские сценарии, включая authentication.
|
||||||
|
- `entities` - доменные модели и их UI/API.
|
||||||
|
- `shared` - переиспользуемый код без доменной логики: API, i18n и UI.
|
||||||
|
|
||||||
|
Каждый слайс предоставляет наружу только public API через `index.ts`; внутренние файлы соседний слой не импортирует напрямую.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
Тесты запускаются `npm run test:run` через Vitest и Testing Library.
|
||||||
|
|
||||||
|
Проверяйте наблюдаемое поведение, а не детали реализации. UI-тест строится как Arrange, Act, Assert; для интерактивных действий предпочитайте `userEvent`. Форма должна проверять валидацию и отсутствие запроса при невалидных данных; маршруты - редиректы и доступность экрана.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# UI Pattern
|
||||||
|
|
||||||
|
Формы используют `react-hook-form`, `zodResolver` и `FormProvider`. Form state создаётся в контейнере или VM, чистое представление получает `onSubmit` и `disabled`, а поля подключает через `useFormContext`.
|
||||||
|
|
||||||
|
Во время submit все действия формы блокируются. Ошибки Zod отображаются у поля; ошибки сети сохраняют введённые значения и показываются отдельным `Alert`.
|
||||||
|
|
||||||
|
Запросные экраны выбирают состояние в порядке loading, error, empty, loaded. Состояния имеют отдельные компоненты, если повторяются или содержат самостоятельную разметку.
|
||||||
Generated
+3780
-3636
File diff suppressed because it is too large
Load Diff
+9
-6
@@ -15,31 +15,34 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^5.7.1",
|
||||||
"@mantine/core": "^8.3.14",
|
"@mantine/core": "^8.3.14",
|
||||||
"@tanstack/react-query": "^5.90.3",
|
"@tanstack/react-query": "^5.90.3",
|
||||||
"i18next": "^26.0.2",
|
"i18next": "^26.0.2",
|
||||||
"openapi-fetch": "^0.17.0",
|
"openapi-fetch": "^0.17.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
"react-hook-form": "^7.85.0",
|
||||||
"react-i18next": "^16.3.5",
|
"react-i18next": "^16.3.5",
|
||||||
"react-router": "^7.9.6"
|
"react-router": "^7.9.6",
|
||||||
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.29.7",
|
"@babel/core": "^7.29.7",
|
||||||
"@biomejs/biome": "2.5.8",
|
"@biomejs/biome": "2.5.8",
|
||||||
"@rolldown/plugin-babel": "^0.2.3",
|
"@rolldown/plugin-babel": "^0.2.3",
|
||||||
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
"@types/babel__core": "^7.20.5",
|
"@types/babel__core": "^7.20.5",
|
||||||
"@types/node": "^24.13.3",
|
"@types/node": "^24.13.3",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
|
||||||
"@testing-library/react": "^16.3.0",
|
|
||||||
"@vitejs/plugin-react": "^6.0.4",
|
"@vitejs/plugin-react": "^6.0.4",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"jsdom": "^27.4.0",
|
||||||
|
"openapi-typescript": "^7.10.1",
|
||||||
"typescript": "~5.9.3",
|
"typescript": "~5.9.3",
|
||||||
"vite": "^8.2.0",
|
"vite": "^8.2.0",
|
||||||
"vitest": "^4.0.16",
|
"vitest": "^4.0.16"
|
||||||
"jsdom": "^27.4.0",
|
|
||||||
"openapi-typescript": "^7.10.1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
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 { MemoryRouter } from "react-router";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { AuthProvider } from "../../features/auth";
|
||||||
import {
|
import {
|
||||||
AccountsPageEmpty,
|
AccountsPageEmpty,
|
||||||
AccountsPageError,
|
AccountsPageError,
|
||||||
@@ -12,43 +14,46 @@ import { AppRoutes } from "./AppRoutes";
|
|||||||
|
|
||||||
describe("AppRoutes", () => {
|
describe("AppRoutes", () => {
|
||||||
afterEach(cleanup);
|
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([
|
it.each([
|
||||||
["/", "Счета"],
|
["/", "Вход"],
|
||||||
["/login", "Вход"],
|
["/login", "Вход"],
|
||||||
["/register", "Регистрация"],
|
["/register", "Регистрация"],
|
||||||
["/accounts", "Счета"],
|
["/accounts", "Вход"],
|
||||||
["/accounts/demo", "Счет"],
|
["/accounts/demo", "Вход"],
|
||||||
["/settings", "Настройки"],
|
["/settings", "Вход"],
|
||||||
["/unknown", "Страница не найдена"],
|
["/unknown", "Страница не найдена"],
|
||||||
])("renders %s with its page title", (path, title) => {
|
])("renders %s with its page title", async (path, title) => {
|
||||||
render(
|
renderRoutes(path);
|
||||||
<MemoryRouter initialEntries={[path]}>
|
|
||||||
<MantineProvider theme={theme}>
|
|
||||||
<AppRoutes />
|
|
||||||
</MantineProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
await waitFor(() =>
|
||||||
screen.getByRole("heading", { level: 1, name: title }),
|
expect(
|
||||||
).toBeInTheDocument();
|
screen.getByRole("heading", { level: 1, name: title }),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders only the empty state for accounts by default", () => {
|
it("redirects anonymous users from accounts", async () => {
|
||||||
render(
|
renderRoutes("/accounts");
|
||||||
<MemoryRouter initialEntries={["/accounts"]}>
|
|
||||||
<MantineProvider theme={theme}>
|
|
||||||
<AppRoutes />
|
|
||||||
</MantineProvider>
|
|
||||||
</MemoryRouter>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(
|
await waitFor(() =>
|
||||||
screen.getByRole("heading", { level: 2, name: "Пока ничего нет" }),
|
expect(
|
||||||
).toBeInTheDocument();
|
screen.getByRole("heading", { level: 1, name: "Вход" }),
|
||||||
expect(screen.queryByText("Загрузка")).not.toBeInTheDocument();
|
).toBeInTheDocument(),
|
||||||
expect(screen.queryByText("Что-то пошло не так")).not.toBeInTheDocument();
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
|
|||||||
@@ -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 { AccountPage } from "../../pages/account";
|
||||||
import { AccountsPage } from "../../pages/accounts";
|
import { AccountsPage } from "../../pages/accounts";
|
||||||
import { LoginPage } from "../../pages/login";
|
import { LoginPage } from "../../pages/login";
|
||||||
@@ -8,13 +8,54 @@ import { RegisterPage } from "../../pages/register";
|
|||||||
import { SettingsPage } from "../../pages/settings";
|
import { SettingsPage } from "../../pages/settings";
|
||||||
import { AppShell } from "../../shared/ui/AppShell";
|
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() {
|
export function AppRoutes() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/accounts" replace />} />
|
<Route path="/" element={<Navigate to="/accounts" replace />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
path="/login"
|
||||||
<Route element={<AppShell />}>
|
element={
|
||||||
|
<Public>
|
||||||
|
<LoginPage />
|
||||||
|
</Public>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/register"
|
||||||
|
element={
|
||||||
|
<Public>
|
||||||
|
<RegisterPage />
|
||||||
|
</Public>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
element={
|
||||||
|
<Protected>
|
||||||
|
<AppShell />
|
||||||
|
</Protected>
|
||||||
|
}
|
||||||
|
>
|
||||||
<Route path="/accounts" element={<AccountsPage />} />
|
<Route path="/accounts" element={<AccountsPage />} />
|
||||||
<Route path="/accounts/:accountId" element={<AccountPage />} />
|
<Route path="/accounts/:accountId" element={<AccountPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ a {
|
|||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
display: none;
|
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) {
|
@media (max-width: 47.99375em) {
|
||||||
.bottom-nav {
|
.bottom-nav {
|
||||||
background: var(--ft-color-bg-surface);
|
background: var(--ft-color-bg-surface);
|
||||||
|
|||||||
+4
-2
@@ -1,7 +1,7 @@
|
|||||||
import { MantineProvider } from "@mantine/core";
|
import { MantineProvider } from "@mantine/core";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { BrowserRouter } from "react-router";
|
import { BrowserRouter } from "react-router";
|
||||||
|
import { AuthProvider } from "../../features/auth";
|
||||||
import { AppRoutes } from "../routes";
|
import { AppRoutes } from "../routes";
|
||||||
import { theme } from "../theme";
|
import { theme } from "../theme";
|
||||||
|
|
||||||
@@ -14,7 +14,9 @@ export function App() {
|
|||||||
<MantineProvider theme={theme} forceColorScheme="light">
|
<MantineProvider theme={theme} forceColorScheme="light">
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AppRoutes />
|
<AuthProvider>
|
||||||
|
<AppRoutes />
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { AuthProvider, useAuth } from "./model/auth";
|
||||||
|
export { LoginForm } from "./ui/LoginForm";
|
||||||
|
export { RegisterForm } from "./ui/RegisterForm";
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,19 @@
|
|||||||
import { Stack, Title } from "@mantine/core";
|
import { Paper, Stack, Title } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { LoginForm } from "../../../features/auth";
|
||||||
import { StateView } from "../../../shared/ui/StateView";
|
import { StateView } from "../../../shared/ui/StateView";
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<main className="auth-page">
|
||||||
<Title order={1}>{t("auth.login")}</Title>
|
<Paper className="auth-card" p="xl" radius="lg" withBorder>
|
||||||
<LoginPageEmpty />
|
<Stack gap="lg">
|
||||||
</Stack>
|
<Title order={1}>{t("auth.login")}</Title>
|
||||||
|
<LoginForm />
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { Stack, Title } from "@mantine/core";
|
import { Paper, Stack, Title } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { RegisterForm } from "../../../features/auth";
|
||||||
import { StateView } from "../../../shared/ui/StateView";
|
import { StateView } from "../../../shared/ui/StateView";
|
||||||
|
|
||||||
export function RegisterPage() {
|
export function RegisterPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<main className="auth-page">
|
||||||
<Title order={1}>{t("auth.register")}</Title>
|
<Paper className="auth-card" p="xl" radius="lg" withBorder>
|
||||||
<RegisterPageEmpty />
|
<Stack gap="lg">
|
||||||
</Stack>
|
<Title order={1}>{t("auth.register")}</Title>
|
||||||
|
<RegisterForm />
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
import { Stack, Title } from "@mantine/core";
|
import { Button, Stack, Title } from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
import { useAuth } from "../../../features/auth";
|
||||||
import { StateView } from "../../../shared/ui/StateView";
|
import { StateView } from "../../../shared/ui/StateView";
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { logout } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
<Title order={1}>{t("settings.title")}</Title>
|
<Title order={1}>{t("settings.title")}</Title>
|
||||||
<SettingsPageEmpty />
|
<Button
|
||||||
|
onClick={async () => {
|
||||||
|
await logout();
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("auth.logout")}
|
||||||
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,4 +3,7 @@ import createClient from "openapi-fetch";
|
|||||||
import { apiBaseUrl } from "./config";
|
import { apiBaseUrl } from "./config";
|
||||||
import type { paths } from "./generated/schema";
|
import type { paths } from "./generated/schema";
|
||||||
|
|
||||||
export const apiClient = createClient<paths>({ baseUrl: apiBaseUrl });
|
export const apiClient = createClient<paths>({
|
||||||
|
baseUrl: apiBaseUrl,
|
||||||
|
credentials: "include",
|
||||||
|
});
|
||||||
|
|||||||
Vendored
+157
-14
@@ -4,14 +4,78 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export interface paths {
|
export interface paths {
|
||||||
"/api/v1": {
|
"/api/v1/auth/register": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
cookie?: 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;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
delete?: never;
|
delete?: never;
|
||||||
@@ -24,13 +88,19 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
SafeErrorResponseDto: {
|
CredentialsDto: {
|
||||||
/** @example 500 */
|
/** @example user@example.com */
|
||||||
statusCode: number;
|
email: string;
|
||||||
/** @example Internal server error */
|
/** @example a secure password */
|
||||||
message: string;
|
password: string;
|
||||||
/** @example /api/v1 */
|
};
|
||||||
path: string;
|
UserDto: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
};
|
||||||
|
AuthenticationDto: {
|
||||||
|
accessToken: string;
|
||||||
|
user: components["schemas"]["UserDto"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
responses: never;
|
responses: never;
|
||||||
@@ -41,7 +111,53 @@ export interface components {
|
|||||||
}
|
}
|
||||||
export type $defs = Record<string, never>;
|
export type $defs = Record<string, never>;
|
||||||
export interface operations {
|
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: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -55,16 +171,43 @@ export interface operations {
|
|||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
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: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
content: {
|
content: {
|
||||||
"application/json": components["schemas"]["SafeErrorResponseDto"];
|
"application/json": components["schemas"]["UserDto"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ void i18n.use(initReactI18next).init({
|
|||||||
interpolation: { escapeValue: false },
|
interpolation: { escapeValue: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export { validationMessage } from "./zod";
|
||||||
export { i18n };
|
export { i18n };
|
||||||
|
|||||||
@@ -2,7 +2,22 @@ export const ru = {
|
|||||||
translation: {
|
translation: {
|
||||||
appName: "WealthLedger",
|
appName: "WealthLedger",
|
||||||
navigation: { accounts: "Счета", settings: "Настройки" },
|
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: "Счета" },
|
accounts: { title: "Счета" },
|
||||||
account: { title: "Счет" },
|
account: { title: "Счет" },
|
||||||
settings: { title: "Настройки" },
|
settings: { title: "Настройки" },
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user