feat: add registration and authentication
This commit is contained in:
@@ -1,2 +1,4 @@
|
||||
# Для локального npm run dev дополнительных VITE-переменных не требуется.
|
||||
# Для production-регистрации укажите публичный site key Cloudflare Turnstile.
|
||||
# Без значения локально используется dev bypass token, если он разрешён бэкендом.
|
||||
# VITE_TURNSTILE_SITE_KEY=
|
||||
# Полный список переменных окружения — в корневом .env.example репозитория.
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "calendar-run-frontend",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "calendar-run-frontend",
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "calendar-run-frontend",
|
||||
"private": true,
|
||||
"version": "0.6.1",
|
||||
"version": "0.7.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
68
frontend/src/app/auth/AuthContext.tsx
Normal file
68
frontend/src/app/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
||||
import type { AuthUser } from "../../api";
|
||||
import { ApiError, getCurrentUser, login as loginRequest, logout as logoutRequest } from "../../api";
|
||||
|
||||
interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isLoading: boolean;
|
||||
login(email: string, password: string): Promise<void>;
|
||||
logout(): Promise<void>;
|
||||
refresh(): Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function AuthProvider(props: { children: React.ReactNode }): JSX.Element {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const response = await getCurrentUser();
|
||||
setUser(response.user);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
setUser(null);
|
||||
return;
|
||||
}
|
||||
setUser(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
void refresh().finally(() => {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const response = await loginRequest({ email, password });
|
||||
setUser(response.user);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
await logoutRequest();
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ user, isLoading, login, logout, refresh }),
|
||||
[user, isLoading, login, logout, refresh],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error("useAuth must be used inside AuthProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
26
frontend/src/app/auth/RequireAuth.tsx
Normal file
26
frontend/src/app/auth/RequireAuth.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
||||
import { useAuth } from "./AuthContext";
|
||||
|
||||
export function RequireAuth(): JSX.Element {
|
||||
const { user, isLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="page page--auth" aria-busy="true">
|
||||
<h1 className="page__title">Загрузка</h1>
|
||||
<p className="page__subtitle">Проверяем сессию...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
}
|
||||
|
||||
if (!user.emailVerifiedAt) {
|
||||
return <Navigate to="/verify-email" replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Link, NavLink, Outlet } from "react-router-dom";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { AppShellFooter } from "./AppShellFooter";
|
||||
|
||||
export function AppLayout(): JSX.Element {
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="app-shell__header">
|
||||
@@ -34,6 +37,20 @@ export function AppLayout(): JSX.Element {
|
||||
>
|
||||
+ Добавить
|
||||
</NavLink>
|
||||
{user ? (
|
||||
<button className="app-shell__link app-shell__link--button" type="button" onClick={() => void logout()}>
|
||||
Выйти
|
||||
</button>
|
||||
) : (
|
||||
<NavLink
|
||||
to="/login"
|
||||
className={({ isActive }) =>
|
||||
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
|
||||
}
|
||||
>
|
||||
Войти
|
||||
</NavLink>
|
||||
)}
|
||||
</nav>
|
||||
</header>
|
||||
<main className="app-shell__main">
|
||||
|
||||
@@ -5,18 +5,30 @@ import { RacesPage } from "../pages/RacesPage";
|
||||
import { RaceDetailsPage } from "../pages/RaceDetailsPage";
|
||||
import { RaceFormPage } from "../pages/RaceFormPage";
|
||||
import { RaceDayPage } from "../pages/RaceDayPage";
|
||||
import { ForgotPasswordPage, LoginPage, RegisterPage, ResetPasswordPage, VerifyEmailPage } from "../pages/AuthPages";
|
||||
import { RequireAuth } from "./auth/RequireAuth";
|
||||
|
||||
export const appRouter = createBrowserRouter([
|
||||
{
|
||||
path: "/",
|
||||
element: <AppLayout />,
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: "races", element: <RacesPage /> },
|
||||
{ path: "races/new", element: <RaceFormPage /> },
|
||||
{ path: "races/day/:ymd", element: <RaceDayPage /> },
|
||||
{ path: "races/:raceId", element: <RaceDetailsPage /> },
|
||||
{ path: "races/:raceId/edit", element: <RaceFormPage /> },
|
||||
{ path: "login", element: <LoginPage /> },
|
||||
{ path: "register", element: <RegisterPage /> },
|
||||
{ path: "verify-email", element: <VerifyEmailPage /> },
|
||||
{ path: "forgot-password", element: <ForgotPasswordPage /> },
|
||||
{ path: "reset-password", element: <ResetPasswordPage /> },
|
||||
{
|
||||
element: <RequireAuth />,
|
||||
children: [
|
||||
{ index: true, element: <DashboardPage /> },
|
||||
{ path: "races", element: <RacesPage /> },
|
||||
{ path: "races/new", element: <RaceFormPage /> },
|
||||
{ path: "races/day/:ymd", element: <RaceDayPage /> },
|
||||
{ path: "races/:raceId", element: <RaceDetailsPage /> },
|
||||
{ path: "races/:raceId/edit", element: <RaceFormPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
import { AuthProvider } from "./app/auth/AuthContext";
|
||||
import { appRouter } from "./app/router";
|
||||
import "./styles/tokens.css";
|
||||
import "./styles/global.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={appRouter} />
|
||||
<AuthProvider>
|
||||
<RouterProvider router={appRouter} />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
253
frontend/src/pages/AuthPages.tsx
Normal file
253
frontend/src/pages/AuthPages.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Link, Navigate, useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ApiError,
|
||||
forgotPassword,
|
||||
register,
|
||||
resendVerification,
|
||||
resetPassword,
|
||||
verifyEmail,
|
||||
} from "../api";
|
||||
import { useAuth } from "../app/auth/AuthContext";
|
||||
|
||||
function errorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.details.length > 0 ? error.details.join("; ") : error.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function TurnstileField(props: { onToken(token: string): void }): JSX.Element {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
|
||||
const { onToken } = props;
|
||||
|
||||
useEffect(() => {
|
||||
if (!siteKey || !ref.current) {
|
||||
onToken("mock-turnstile-token");
|
||||
return;
|
||||
}
|
||||
|
||||
const scriptId = "turnstile-script";
|
||||
if (!document.getElementById(scriptId)) {
|
||||
const script = document.createElement("script");
|
||||
script.id = scriptId;
|
||||
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
let widgetId: string | null = null;
|
||||
const timer = window.setInterval(() => {
|
||||
if (window.turnstile && ref.current && !widgetId) {
|
||||
widgetId = window.turnstile.render(ref.current, {
|
||||
sitekey: siteKey,
|
||||
callback: onToken,
|
||||
"expired-callback": () => onToken(""),
|
||||
});
|
||||
window.clearInterval(timer);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
if (widgetId && window.turnstile) {
|
||||
window.turnstile.remove(widgetId);
|
||||
}
|
||||
};
|
||||
}, [onToken, siteKey]);
|
||||
|
||||
return <div className="auth-form__captcha" ref={ref} />;
|
||||
}
|
||||
|
||||
export function LoginPage(): JSX.Element {
|
||||
const { user, login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
if (user?.emailVerifiedAt) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await login(email, password);
|
||||
const from = (location.state as { from?: Location } | null)?.from?.pathname ?? "/";
|
||||
navigate(from, { replace: true });
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "Не удалось войти."));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="page page--auth">
|
||||
<h1 className="page__title">Вход</h1>
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label className="auth-form__field">
|
||||
<span className="auth-form__label">Email</span>
|
||||
<input className="auth-form__input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</label>
|
||||
<label className="auth-form__field">
|
||||
<span className="auth-form__label">Пароль</span>
|
||||
<input className="auth-form__input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
</label>
|
||||
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
|
||||
<button className="btn btn--primary" type="submit" disabled={isSubmitting}>
|
||||
Войти
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-form__links">
|
||||
<Link className="page-link" to="/register">Регистрация</Link>
|
||||
<Link className="page-link" to="/forgot-password">Забыли пароль?</Link>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegisterPage(): JSX.Element {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [turnstileToken, setTurnstileToken] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleToken = useCallback((token: string) => setTurnstileToken(token), []);
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
try {
|
||||
await register({ email, password, turnstileToken });
|
||||
setMessage("Проверьте почту: мы отправили ссылку для подтверждения email.");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "Не удалось зарегистрироваться."));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="page page--auth">
|
||||
<h1 className="page__title">Регистрация</h1>
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label className="auth-form__field">
|
||||
<span className="auth-form__label">Email</span>
|
||||
<input className="auth-form__input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</label>
|
||||
<label className="auth-form__field">
|
||||
<span className="auth-form__label">Пароль</span>
|
||||
<input className="auth-form__input" type="password" minLength={15} value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
</label>
|
||||
<TurnstileField onToken={handleToken} />
|
||||
{message ? <p className="page__subtitle">{message}</p> : null}
|
||||
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
|
||||
<button className="btn btn--primary" type="submit" disabled={!turnstileToken}>Создать аккаунт</button>
|
||||
</form>
|
||||
<p className="auth-form__links">
|
||||
<Link className="page-link" to="/login">Уже есть аккаунт</Link>
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function VerifyEmailPage(): JSX.Element {
|
||||
const { user, refresh } = useAuth();
|
||||
const [params] = useSearchParams();
|
||||
const [email, setEmail] = useState(user?.email ?? "");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const token = params.get("token");
|
||||
if (!token) {
|
||||
return;
|
||||
}
|
||||
void verifyEmail(token)
|
||||
.then(async () => {
|
||||
await refresh();
|
||||
setMessage("Email подтверждён. Теперь можно пользоваться календарём.");
|
||||
})
|
||||
.catch((err) => setError(errorMessage(err, "Ссылка недействительна.")));
|
||||
}, [params, refresh]);
|
||||
|
||||
const resend = async () => {
|
||||
setError(null);
|
||||
await resendVerification(email);
|
||||
setMessage("Если email зарегистрирован и ещё не подтверждён, письмо отправлено.");
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="page page--auth">
|
||||
<h1 className="page__title">Подтверждение email</h1>
|
||||
<p className="page__subtitle">Для доступа к календарю подтвердите email по ссылке из письма.</p>
|
||||
<form className="auth-form" onSubmit={(e) => { e.preventDefault(); void resend(); }}>
|
||||
<label className="auth-form__field">
|
||||
<span className="auth-form__label">Email</span>
|
||||
<input className="auth-form__input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</label>
|
||||
{message ? <p className="page__subtitle">{message}</p> : null}
|
||||
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
|
||||
<button className="btn btn--primary" type="submit">Отправить письмо ещё раз</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForgotPasswordPage(): JSX.Element {
|
||||
const [email, setEmail] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<section className="page page--auth">
|
||||
<h1 className="page__title">Сброс пароля</h1>
|
||||
<form className="auth-form" onSubmit={async (e) => { e.preventDefault(); await forgotPassword(email); setMessage("Если email зарегистрирован, ссылка отправлена."); }}>
|
||||
<label className="auth-form__field">
|
||||
<span className="auth-form__label">Email</span>
|
||||
<input className="auth-form__input" type="email" value={email} onChange={(event) => setEmail(event.target.value)} required />
|
||||
</label>
|
||||
{message ? <p className="page__subtitle">{message}</p> : null}
|
||||
<button className="btn btn--primary" type="submit">Отправить ссылку</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ResetPasswordPage(): JSX.Element {
|
||||
const [params] = useSearchParams();
|
||||
const [password, setPassword] = useState("");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<section className="page page--auth">
|
||||
<h1 className="page__title">Новый пароль</h1>
|
||||
<form className="auth-form" onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const token = params.get("token") ?? "";
|
||||
try {
|
||||
await resetPassword(token, password);
|
||||
setMessage("Пароль обновлён. Теперь войдите заново.");
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "Не удалось обновить пароль."));
|
||||
}
|
||||
}}>
|
||||
<label className="auth-form__field">
|
||||
<span className="auth-form__label">Пароль</span>
|
||||
<input className="auth-form__input" type="password" minLength={15} value={password} onChange={(event) => setPassword(event.target.value)} required />
|
||||
</label>
|
||||
{message ? <p className="page__subtitle">{message}</p> : null}
|
||||
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
|
||||
<button className="btn btn--primary" type="submit">Сохранить пароль</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -224,9 +224,9 @@ export function RaceFormPage(): JSX.Element {
|
||||
await updateRace(raceId, payload);
|
||||
navigate(`/races/${raceId}`);
|
||||
} else {
|
||||
const id = generateId(form.date.trim(), form.title.trim());
|
||||
const slug = generateId(form.date.trim(), form.title.trim());
|
||||
const payload: CreateRacePayload = {
|
||||
id,
|
||||
slug,
|
||||
date: form.date.trim(),
|
||||
title: form.title.trim(),
|
||||
distanceKm: parseFloat(form.distanceKm),
|
||||
|
||||
@@ -63,6 +63,13 @@ a {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.app-shell__link--button {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.app-shell__link:hover,
|
||||
.app-shell__link:focus-visible {
|
||||
color: var(--color-text);
|
||||
@@ -122,6 +129,54 @@ a {
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.page--auth {
|
||||
max-width: 32rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
margin-top: var(--space-5);
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.auth-form__field {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.auth-form__label {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.auth-form__input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: var(--space-3);
|
||||
font: inherit;
|
||||
color: var(--color-text);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.auth-form__input:focus {
|
||||
border-color: var(--color-accent);
|
||||
outline: 2px solid color-mix(in srgb, var(--color-accent) 22%, transparent);
|
||||
}
|
||||
|
||||
.auth-form__captcha {
|
||||
min-height: 4rem;
|
||||
}
|
||||
|
||||
.auth-form__links {
|
||||
margin: var(--space-5) 0 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
margin-top: var(--space-6);
|
||||
display: grid;
|
||||
@@ -1358,7 +1413,8 @@ body {
|
||||
.dashboard-hero--with-image {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(7, 25, 39, 0.94) 0%, rgba(7, 25, 39, 0.68) 48%, rgba(7, 25, 39, 0.2) 100%),
|
||||
var(--dashboard-hero-image) center / cover;
|
||||
var(--dashboard-hero-image) center / cover,
|
||||
url("/images/runner-hero.jpg") center / cover;
|
||||
}
|
||||
|
||||
.dashboard-hero__content,
|
||||
|
||||
11
frontend/src/vite-env.d.ts
vendored
11
frontend/src/vite-env.d.ts
vendored
@@ -1 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_TURNSTILE_SITE_KEY?: string;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render(container: HTMLElement, options: { sitekey: string; callback(token: string): void; "expired-callback"(): void }): string;
|
||||
remove(widgetId: string): void;
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user