feat: add registration and authentication
This commit is contained in:
69
frontend/src/api/auth.ts
Normal file
69
frontend/src/api/auth.ts
Normal 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 }),
|
||||
});
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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 ?? {}),
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user