feat: add registration and authentication

This commit is contained in:
Vaka.pro
2026-05-21 00:01:35 +03:00
parent 13dd8fa426
commit 35c3554742
37 changed files with 2162 additions and 81 deletions

69
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,69 @@
import { requestJson, setCsrfToken } from "./http";
import type { AuthUser } from "./types";
interface AuthResponse {
user: AuthUser;
csrfToken: string | null;
}
function applyAuthResponse(response: AuthResponse): AuthResponse {
setCsrfToken(response.csrfToken);
return response;
}
export async function getCurrentUser(): Promise<AuthResponse> {
return applyAuthResponse(await requestJson<AuthResponse>("/auth/me"));
}
export async function register(payload: {
email: string;
password: string;
turnstileToken: string;
}): Promise<void> {
await requestJson<void>("/auth/register", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function login(payload: { email: string; password: string }): Promise<AuthResponse> {
return applyAuthResponse(
await requestJson<AuthResponse>("/auth/login", {
method: "POST",
body: JSON.stringify(payload),
}),
);
}
export async function logout(): Promise<void> {
await requestJson<void>("/auth/logout", { method: "POST" });
setCsrfToken(null);
}
export async function verifyEmail(token: string): Promise<void> {
await requestJson<void>("/auth/verify-email", {
method: "POST",
body: JSON.stringify({ token }),
});
}
export async function resendVerification(email: string): Promise<void> {
await requestJson<void>("/auth/resend-verification", {
method: "POST",
body: JSON.stringify({ email }),
});
}
export async function forgotPassword(email: string): Promise<void> {
await requestJson<void>("/auth/forgot-password", {
method: "POST",
body: JSON.stringify({ email }),
});
}
export async function resetPassword(token: string, password: string): Promise<void> {
await requestJson<void>("/auth/reset-password", {
method: "POST",
body: JSON.stringify({ token, password }),
});
}

View File

@@ -3,6 +3,12 @@ export type ApiErrorCode =
| "not_found"
| "database_unavailable"
| "conflict"
| "unauthorized"
| "email_not_verified"
| "csrf_error"
| "captcha_failed"
| "invalid_credentials"
| "invalid_token"
| "network_error"
| "unknown_error";
@@ -36,6 +42,12 @@ function normalizeApiCode(value: string | undefined): ApiErrorCode {
value === "not_found" ||
value === "database_unavailable" ||
value === "conflict" ||
value === "unauthorized" ||
value === "email_not_verified" ||
value === "csrf_error" ||
value === "captcha_failed" ||
value === "invalid_credentials" ||
value === "invalid_token" ||
value === "unknown_error"
) {
return value;
@@ -98,6 +110,18 @@ export function getApiErrorMessage(code: ApiErrorCode): string {
return "Сервис временно недоступен. Попробуйте позже.";
case "conflict":
return "Запись с таким идентификатором уже существует.";
case "unauthorized":
return "Нужно войти в аккаунт.";
case "email_not_verified":
return "Подтвердите email, чтобы продолжить.";
case "csrf_error":
return "Сессия устарела. Обновите страницу и попробуйте снова.";
case "captcha_failed":
return "Проверка капчи не пройдена.";
case "invalid_credentials":
return "Неверный email или пароль.";
case "invalid_token":
return "Ссылка недействительна или устарела.";
case "network_error":
return "Не удалось связаться с сервером.";
case "unknown_error":

View File

@@ -1,6 +1,11 @@
import { ApiError, isStructuredApiErrorPayload, toApiError } from "./errors";
const API_ROOT = "/api";
let csrfToken: string | null = null;
export function setCsrfToken(token: string | null): void {
csrfToken = token;
}
function buildUrl(path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
@@ -61,9 +66,13 @@ export async function requestJson<T>(path: string, init?: RequestInit): Promise<
if (method !== "GET" && method !== "HEAD") {
defaultHeaders["Content-Type"] = "application/json";
}
if (csrfToken && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
defaultHeaders["X-CSRF-Token"] = csrfToken;
}
const response = await fetch(buildUrl(path), {
...init,
credentials: "include",
headers: {
...defaultHeaders,
...(init?.headers ?? {}),

View File

@@ -1,5 +1,15 @@
export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
export type { AuthUser, CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
export { ApiError, getApiErrorMessage } from "./errors";
export type { BackendMetaResponse, HealthResponse } from "./health";
export { getBackendMeta, getHealth } from "./health";
export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races";
export {
forgotPassword,
getCurrentUser,
login,
logout,
register,
resendVerification,
resetPassword,
verifyEmail,
} from "./auth";

View File

@@ -19,6 +19,7 @@ function normalizeRace(input: unknown): Race {
const isValid =
isString(race?.id) &&
isString(race?.slug) &&
isString(race?.date) &&
isString(race?.title) &&
typeof race?.distanceKm === "number" &&
@@ -48,6 +49,7 @@ function normalizeRace(input: unknown): Race {
return {
id: race.id,
slug: race.slug,
date: race.date,
title: race.title,
distanceKm: race.distanceKm,

View File

@@ -2,6 +2,7 @@ export type RaceStatus = "planned" | "registered" | "completed";
export interface Race {
id: string;
slug: string;
date: string;
title: string;
distanceKm: number;
@@ -25,7 +26,7 @@ export interface RacesQuery {
}
export interface CreateRacePayload {
id: string;
slug: string;
date: string;
title: string;
distanceKm: number;
@@ -42,3 +43,9 @@ export interface CreateRacePayload {
}
export type UpdateRacePayload = Partial<Omit<CreateRacePayload, "id">>;
export interface AuthUser {
id: string;
email: string;
emailVerifiedAt: string | null;
}