From 793d51fdce114931df77719ddcbe10ccb3e40782 Mon Sep 17 00:00:00 2001 From: Vakanaut Date: Sun, 12 Jul 2026 15:56:26 +0300 Subject: [PATCH] feat: complete account and dashboard sprint --- CHANGELOG.md | 37 +++-- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/authService.ts | 94 +++++++++++++ backend/src/routes/auth.ts | 76 +++++++++- backend/test/app.test.ts | 76 ++++++++++ docs/backend-api-for-frontend.md | 42 ++++++ docs/product-backlog.md | 14 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/api/auth.ts | 22 ++- frontend/src/api/index.ts | 6 +- frontend/src/api/types.ts | 8 ++ frontend/src/app/layouts/AppLayout.tsx | 16 ++- frontend/src/app/router.tsx | 2 + frontend/src/components/PaceTrendChart.tsx | 155 +++++++++++---------- frontend/src/pages/AccountPage.tsx | 131 +++++++++++++++++ frontend/src/pages/DashboardPage.tsx | 87 ++++++++++-- frontend/src/styles/global.css | 117 ++++++++++++++++ 19 files changed, 781 insertions(+), 114 deletions(-) create mode 100644 frontend/src/pages/AccountPage.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f63d4e..fc0d972 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,22 @@ ## В работе -### Backend 1.4.2 +### Backend 1.5.0 + +#### Added + +- Управление паролем и сессиями: проверка текущего пароля, список активных сессий, отзыв выбранной или всех остальных сессий. + +### Frontend 0.8.0 + +#### Added + +- Экран «Аккаунт»: подтверждённый email, смена пароля и управление другими активными сессиями. +- На дашборде появились фильтруемые «Последние старты» с пагинацией и переходом в старт, ссылки из карточек рекордов и интерактивный график прогресса. + +## Backend + +### [1.4.2] — 2026-07-12 #### Changed @@ -15,17 +30,6 @@ - Контракт API стартов актуализирован: UUID `id`, пользовательский `slug`, авторизация, CSRF и строгая валидация запросов. - Получение обложки по сайту организатора ограничено безопасными публичными HTTP(S)-адресами, редиректами, размером и временем ответа. -### Frontend 0.7.1 - -#### Changed - -- Формы используют нативные поля даты и времени старта; результаты вводятся отдельными полями времени и места. -- Подтверждение email завершается явным экраном успеха, auth-формы показывают состояние отправки и ошибки. -- Карточка личного рекорда отображается только при реальном улучшении результата. -- Удалены сгенерированные Vite/TypeScript-файлы, неиспользуемый API-клиент и статический каталог обложек. - -## Backend - ### [1.4.1] — 2026-05-24 #### Changed @@ -87,6 +91,15 @@ ## Frontend +### [0.7.1] — 2026-07-12 + +#### Changed + +- Формы используют нативные поля даты и времени старта; результаты вводятся отдельными полями времени и места. +- Подтверждение email завершается явным экраном успеха, auth-формы показывают состояние отправки и ошибки. +- Карточка личного рекорда отображается только при реальном улучшении результата. +- Удалены сгенерированные Vite/TypeScript-файлы, неиспользуемый API-клиент и статический каталог обложек. + ### [0.7.0] — 2026-05-24 #### Added diff --git a/backend/package-lock.json b/backend/package-lock.json index 4362722..2a113e6 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-backend", - "version": "1.4.2", + "version": "1.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-backend", - "version": "1.4.2", + "version": "1.5.0", "dependencies": { "argon2": "^0.44.0", "cookie-parser": "^1.4.7", diff --git a/backend/package.json b/backend/package.json index 65c2ec0..c597bb8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "calendar-run-backend", - "version": "1.4.2", + "version": "1.5.0", "private": true, "scripts": { "build": "tsc", diff --git a/backend/src/authService.ts b/backend/src/authService.ts index c1630e9..dbdc246 100644 --- a/backend/src/authService.ts +++ b/backend/src/authService.ts @@ -37,6 +37,22 @@ interface SessionRow { email_verified_at: Date | string | null; } +interface SessionListRow { + id: string; + created_at: Date | string; + last_seen_at: Date | string; + expires_at: Date | string; + current: boolean; +} + +export interface ActiveSession { + id: string; + createdAt: string; + lastSeenAt: string; + expiresAt: string; + current: boolean; +} + function toIso(value: Date | string | null): string | null { if (!value) { return null; @@ -198,6 +214,84 @@ export async function revokeSession(sessionToken: string): Promise { securityLog("session.revoked"); } +export async function listActiveSessions(userId: string, sessionToken: string): Promise { + const { rows } = await pool.query( + `SELECT id, created_at, last_seen_at, expires_at, token_hash = $2 AS current + FROM sessions + WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW() + ORDER BY created_at DESC`, + [userId, sha256Hex(sessionToken)], + ); + return rows.map((row) => ({ + id: row.id, + createdAt: toIso(row.created_at)!, + lastSeenAt: toIso(row.last_seen_at)!, + expiresAt: toIso(row.expires_at)!, + current: row.current, + })); +} + +export async function revokeSessionById(userId: string, sessionId: string, sessionToken: string): Promise<{ revoked: boolean; current: boolean }> { + const { rows } = await pool.query<{ current: boolean }>( + `UPDATE sessions + SET revoked_at = NOW() + WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > NOW() + RETURNING token_hash = $3 AS current`, + [sessionId, userId, sha256Hex(sessionToken)], + ); + const row = rows[0]; + if (!row) { + return { revoked: false, current: false }; + } + securityLog("session.revoked", { userId }); + return { revoked: true, current: row.current }; +} + +export async function revokeOtherSessions(userId: string, sessionToken: string): Promise { + await pool.query( + `UPDATE sessions + SET revoked_at = NOW() + WHERE user_id = $1 AND token_hash <> $2 AND revoked_at IS NULL AND expires_at > NOW()`, + [userId, sha256Hex(sessionToken)], + ); + securityLog("session.others_revoked", { userId }); +} + +export async function changePassword( + userId: string, + sessionToken: string, + currentPassword: string, + newPassword: string, +): Promise { + const newPasswordHash = await hashPassword(newPassword); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + const { rows } = await client.query<{ password_hash: string }>( + "SELECT password_hash FROM users WHERE id = $1 FOR UPDATE", + [userId], + ); + const user = rows[0]; + if (!user || !(await verifyPassword(user.password_hash, currentPassword))) { + await client.query("ROLLBACK"); + return false; + } + await client.query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1", [userId, newPasswordHash]); + await client.query( + "UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND token_hash <> $2 AND revoked_at IS NULL", + [userId, sha256Hex(sessionToken)], + ); + await client.query("COMMIT"); + securityLog("password.changed", { userId }); + return true; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} + export async function rotateCsrf(sessionToken: string): Promise { const csrfToken = randomToken(32); const { rowCount } = await pool.query( diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 64bab73..773fdb8 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -2,16 +2,20 @@ import { Router, Request, Response } from "express"; import rateLimit from "express-rate-limit"; import { z } from "zod"; import { + changePassword, + listActiveSessions, loginUser, registerUser, requestPasswordReset, resendVerification, resetPassword, + revokeOtherSessions, + revokeSessionById, rotateCsrf, revokeSession, verifyEmailToken, } from "../authService"; -import { clearSessionCookie, setSessionCookie } from "../authMiddleware"; +import { clearSessionCookie, requireAuth, setSessionCookie } from "../authMiddleware"; import { isValidPassword, normalizeEmail } from "../security"; import { verifyTurnstileToken } from "../turnstile"; @@ -37,6 +41,13 @@ const emailAddressLimiter = rateLimit({ legacyHeaders: false, keyGenerator: (req) => `email:${normalizeEmail(String(req.body?.email ?? ""))}`, }); +const passwordChangeLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + limit: 5, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => `password:${req.auth?.user.id ?? req.ip}`, +}); const registerSchema = z.object({ email: z.string().email(), @@ -62,6 +73,13 @@ const resetSchema = z.object({ password: z.string().refine(isValidPassword, "Password must be at least 15 characters"), }); +const passwordChangeSchema = z.object({ + currentPassword: z.string().min(1), + newPassword: z.string().refine(isValidPassword, "Password must be at least 15 characters"), +}).strict(); + +const sessionIdSchema = z.string().uuid(); + function validationError(res: Response): void { res.status(400).json({ error: "validation_error", details: ["Invalid request body"] }); } @@ -129,6 +147,62 @@ router.get("/auth/me", async (req: Request, res: Response, next) => { } }); +router.get("/auth/sessions", requireAuth, async (req: Request, res: Response, next) => { + try { + res.json({ sessions: await listActiveSessions(req.auth!.user.id, req.auth!.sessionToken) }); + } catch (error) { + next(error); + } +}); + +router.post("/auth/password", requireAuth, passwordChangeLimiter, async (req: Request, res: Response, next) => { + const parsed = passwordChangeSchema.safeParse(req.body); + if (!parsed.success) { + validationError(res); + return; + } + try { + const changed = await changePassword(req.auth!.user.id, req.auth!.sessionToken, parsed.data.currentPassword, parsed.data.newPassword); + if (!changed) { + res.status(400).json({ error: "invalid_current_password", details: ["Current password is incorrect"] }); + return; + } + res.json(genericOk); + } catch (error) { + next(error); + } +}); + +router.delete("/auth/sessions/:id", requireAuth, async (req: Request, res: Response, next) => { + const parsed = sessionIdSchema.safeParse(req.params.id); + if (!parsed.success) { + validationError(res); + return; + } + try { + const result = await revokeSessionById(req.auth!.user.id, parsed.data, req.auth!.sessionToken); + if (!result.revoked) { + res.status(404).json({ error: "not_found", details: ["Session not found"] }); + return; + } + if (result.current) { + clearSessionCookie(res); + } + res.status(204).end(); + } catch (error) { + next(error); + } +}); + +router.post("/auth/sessions/revoke-others", requireAuth, async (req: Request, res: Response, next) => { + try { + await revokeOtherSessions(req.auth!.user.id, req.auth!.sessionToken); + res.json(genericOk); + } catch (error) { + next(error); + } +}); + router.post("/auth/verify-email", emailIpLimiter, async (req: Request, res: Response, next) => { const parsed = tokenSchema.safeParse(req.body); if (!parsed.success) { diff --git a/backend/test/app.test.ts b/backend/test/app.test.ts index a5433de..3b37a4e 100644 --- a/backend/test/app.test.ts +++ b/backend/test/app.test.ts @@ -64,6 +64,12 @@ async function createUser(email: string, password: string, verified: boolean) { return inserted.rows[0].id; } +async function loginAgent(email: string, password: string) { + const agent = request.agent(app); + const login = await agent.post("/api/auth/login").send({ email, password }).expect(200); + return { agent, csrfToken: login.body.csrfToken as string }; +} + async function countByTokenHash(table: "sessions" | "email_verification_tokens" | "password_reset_tokens", tokenHash: string) { const { rows } = await pool.query<{ count: string }>( `SELECT COUNT(*)::text AS count FROM ${table} WHERE token_hash = $1`, @@ -183,6 +189,76 @@ test("login uses generic response for missing user and wrong password", async () assert.deepEqual(missingUser.body, wrongPassword.body); }); +test("account session endpoints list and revoke only the current user's sessions", async () => { + userCounter += 1; + const email = `sessions${userCounter}@example.com`; + const password = "correct horse battery staple"; + await createVerifiedUser(email, password); + const first = await loginAgent(email, password); + const second = await loginAgent(email, password); + + const listed = await first.agent.get("/api/auth/sessions").expect(200); + assert.equal(listed.body.sessions.length, 2); + assert.ok(listed.body.sessions.every((session: { tokenHash?: unknown }) => session.tokenHash === undefined)); + const other = listed.body.sessions.find((session: { current: boolean }) => !session.current); + assert.ok(other); + + const outsider = await authAgent(); + const foreign = (await outsider.agent.get("/api/auth/sessions").expect(200)).body.sessions[0]; + await first.agent + .delete(`/api/auth/sessions/${foreign.id}`) + .set("X-CSRF-Token", first.csrfToken) + .expect(404); + + await first.agent + .delete(`/api/auth/sessions/${other.id}`) + .set("X-CSRF-Token", first.csrfToken) + .expect(204); + await second.agent.get("/api/auth/me").expect(401); + await first.agent.get("/api/auth/sessions").expect(200).then((res) => assert.equal(res.body.sessions.length, 1)); +}); + +test("password change checks the current password and revokes other sessions", async () => { + userCounter += 1; + const email = `password${userCounter}@example.com`; + const password = "correct horse battery staple"; + const nextPassword = "another correct horse battery staple"; + await createVerifiedUser(email, password); + const first = await loginAgent(email, password); + const second = await loginAgent(email, password); + + await first.agent + .post("/api/auth/password") + .set("X-CSRF-Token", first.csrfToken) + .send({ currentPassword: "wrong password", newPassword: nextPassword }) + .expect(400); + await first.agent + .post("/api/auth/password") + .set("X-CSRF-Token", first.csrfToken) + .send({ currentPassword: password, newPassword: nextPassword }) + .expect(200); + + await second.agent.get("/api/auth/me").expect(401); + await request(app).post("/api/auth/login").send({ email, password }).expect(401); + await request(app).post("/api/auth/login").send({ email, password: nextPassword }).expect(200); +}); + +test("revoke other sessions retains the current session", async () => { + userCounter += 1; + const email = `revoke-others${userCounter}@example.com`; + const password = "correct horse battery staple"; + await createVerifiedUser(email, password); + const first = await loginAgent(email, password); + const second = await loginAgent(email, password); + + await first.agent + .post("/api/auth/sessions/revoke-others") + .set("X-CSRF-Token", first.csrfToken) + .expect(200); + await first.agent.get("/api/auth/me").expect(200); + await second.agent.get("/api/auth/me").expect(401); +}); + test("GET /api/races/:id returns not_found for another user's race", async () => { const first = await authAgent(); const created = await first.agent diff --git a/docs/backend-api-for-frontend.md b/docs/backend-api-for-frontend.md index b78c989..2114b37 100644 --- a/docs/backend-api-for-frontend.md +++ b/docs/backend-api-for-frontend.md @@ -15,6 +15,48 @@ SPA обращается к относительному префиксу `/api` передавать в заголовке `X-CSRF-Token`. Cookie передаётся браузером автоматически; при отдельном origin запрос должен использовать credentials. +## Аккаунт и сессии + +Все маршруты этого раздела требуют авторизации и подтверждённого email. Для +изменяющих состояние запросов обязателен `X-CSRF-Token`. + +### `GET /api/auth/sessions` + +Возвращает активные сессии текущего пользователя. Хеши и сами токены не +возвращаются. + +```json +{ + "sessions": [{ + "id": "UUID", + "createdAt": "ISO datetime", + "lastSeenAt": "ISO datetime", + "expiresAt": "ISO datetime", + "current": true + }] +} +``` + +### `POST /api/auth/password` + +```json +{ "currentPassword": "…", "newPassword": "не менее 15 символов" } +``` + +Проверяет текущий пароль, сохраняет новый и отзывает все остальные активные +сессии. Успех: `200 { "ok": true }`; неверный текущий пароль: `400` +`invalid_current_password`. + +### `DELETE /api/auth/sessions/:id` + +Отзывает указанную активную сессию текущего пользователя. Успех: `204`. Если +отозвана текущая сессия, cookie очищается. Чужая, неактивная или отсутствующая +сессия отвечает `404`. + +### `POST /api/auth/sessions/revoke-others` + +Отзывает все активные сессии, кроме текущей. Успех: `200 { "ok": true }`. + ## Служебные маршруты ### `GET /api/health` diff --git a/docs/product-backlog.md b/docs/product-backlog.md index c39902a..2b99362 100644 --- a/docs/product-backlog.md +++ b/docs/product-backlog.md @@ -6,8 +6,8 @@ | Контур | Версия | Уже работает | |---|---:|---| -| Backend | `1.4.2` | Express + PostgreSQL, миграции и seed, строгий CRUD личных стартов, cookie-сессии, CSRF, подтверждение email, Turnstile, SMTP, SSRF-защита обложек, `/health`, `/ready`, `/meta`, CI с PostgreSQL. | -| Frontend | `0.7.1` | Защищённые маршруты и auth-экраны, нативные поля даты/старта, удобный ввод результатов, дашборд с корректным личным рекордом, версия FE/BE в футере. | +| Backend | `1.5.0` | Express + PostgreSQL, миграции и seed, строгий CRUD личных стартов, cookie-сессии, CSRF, подтверждение email, Turnstile, SMTP, смена пароля и управление сессиями, SSRF-защита обложек, `/health`, `/ready`, `/meta`, CI с PostgreSQL. | +| Frontend | `0.8.0` | Защищённые маршруты и auth-экраны, экран аккаунта, нативные поля даты/старта, удобный ввод результатов, дашборд с последними стартами и интерактивным графиком, версия FE/BE в футере. | ## Правила версионирования и выпуска @@ -57,7 +57,7 @@ | ID | Приоритет | Задача | Результат / критерий приёмки | |---|---|---|---| -| BE-150-01 | P0 | Управление паролем и сессиями | Авторизованный пользователь меняет пароль после проверки текущего; все другие сессии отзываются. Есть список активных сессий с отзывом выбранной и «выйти везде». Токены и пароли в ответах/логах не появляются. | +| BE-150-01 | Готово | Управление паролем и сессиями | Выполнено в `1.5.0`: авторизованный пользователь меняет пароль после проверки текущего; все другие сессии отзываются. Есть список активных сессий с отзывом выбранной и «выйти везде». Токены и пароли в ответах/логах не появляются. | | BE-150-02 | P1 | Удаление аккаунта и данных | Явно подтверждённый запрос удаляет аккаунт, его сессии и личные старты транзакционно; повторный запрос идемпотентен. Перед реализацией согласовать необходимость периода восстановления. | | BE-150-03 | P1 | Обратносуместимая фильтрация стартов | Добавить документированные фильтры `status`, диапазон дат и диапазон дистанции, не меняя текущий ответ-массив `GET /races`. Валидация параметров и покрытие индексами — по фактическому query plan. | @@ -65,12 +65,12 @@ | ID | Приоритет | Задача | Результат / критерий приёмки | |---|---|---|---| -| FE-080-01 | P0 | Экран «Аккаунт» | Пользователь видит подтверждённый email, меняет пароль, видит/отзывает сессии и инициирует удаление аккаунта с явным предупреждением. | +| FE-080-01 | Готово | Экран «Аккаунт» | Выполнено в `0.8.0`: пользователь видит подтверждённый email, меняет пароль и видит/отзывает другие сессии. Удаление аккаунта остаётся отдельной задачей BE-150-02. | | FE-080-02 | P1 | Поиск, фильтры и сортировка истории | Фильтры по статусу, периоду и дистанции синхронизированы с URL; поиск по названию работает без новой зависимости. Годы в селекторе строятся из данных, а не ограничены окном «-2/+4». | | FE-080-03 | P1 | Выгрузка личных данных | Из текущей отфильтрованной выборки пользователь скачивает CSV и одноразовый `.ics` в браузере. Сервер не нужен: данные уже принадлежат пользователю и загружены в UI. | -| FE-080-04 | P1 | Сделать на дашборде список последних стартов | Блок «Сравнение стартов» переименован в «Последние старты» и показывает завершённые старты в обратном хронологическом порядке. На странице не более 5 строк, есть клиентская пагинация и фильтры по дистанции и году участия; значения фильтров строятся из данных пользователя, смена фильтра возвращает на первую страницу. Строка ведёт на страницу старта по клику и клавиатуре; состояние выбора анимируется CSS с учётом `prefers-reduced-motion`. API и серверная пагинация не нужны. | -| FE-080-05 | P1 | Связать рекорды по дистанциям со стартами | Карточка рекорда с найденным стартом ведёт на `/races/:id` мышью и с клавиатуры; она использует тот же `dashboard-card--linked`/`dashboard-card__link-surface` и анимацию, что «Ближайший старт», «Последний результат» и «Последний личный рекорд». Пустая карточка ссылкой не становится. | -| FE-080-06 | P1 | Сделать график прогресса информативным и интерактивным | SVG-график показывает подписанные оси «Дата» и «Время финиша» с читаемыми делениями; точки компактнее, имеют краткие адаптивные подписи без наложений. Наведение и фокус на точке показывают tooltip с датой, названием, временем и темпом; клик или Enter/Space открывает `/races/:id`. На узких экранах график и подписи не выходят за контейнер, а анимации учитывают `prefers-reduced-motion`. Используются текущие данные без новой библиотеки и API. | +| FE-080-04 | Готово | Сделать на дашборде список последних стартов | Выполнено в `0.8.0`: «Последние старты» показывают до 5 завершённых стартов с клиентской пагинацией, фильтрами по дистанции и году и доступным переходом в старт. | +| FE-080-05 | Готово | Связать рекорды по дистанциям со стартами | Выполнено в `0.8.0`: карточка рекорда с найденным стартом ведёт на `/races/:id` и использует существующую анимацию связанных карточек. | +| FE-080-06 | Готово | Сделать график прогресса информативным и интерактивным | Выполнено в `0.8.0`: график имеет подписанные оси, компактные подписи, tooltip по наведению/фокусу и доступный переход в старт. | ## P2 — BE 1.6.0 / FE 0.9.0 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7917a66..9724119 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-frontend", - "version": "0.7.1", + "version": "0.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-frontend", - "version": "0.7.1", + "version": "0.8.0", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index 1197fe2..c5d1e0b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "calendar-run-frontend", "private": true, - "version": "0.7.1", + "version": "0.8.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 32d74c8..a15fadb 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,5 +1,5 @@ import { requestJson, setCsrfToken } from "./http"; -import type { AuthUser } from "./types"; +import type { AuthSession, AuthUser } from "./types"; interface AuthResponse { user: AuthUser; @@ -67,3 +67,23 @@ export async function resetPassword(token: string, password: string): Promise { + const response = await requestJson<{ sessions: AuthSession[] }>("/auth/sessions"); + return response.sessions; +} + +export async function changePassword(payload: { currentPassword: string; newPassword: string }): Promise { + await requestJson("/auth/password", { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function revokeSession(sessionId: string): Promise { + await requestJson(`/auth/sessions/${sessionId}`, { method: "DELETE" }); +} + +export async function revokeOtherSessions(): Promise { + await requestJson("/auth/sessions/revoke-others", { method: "POST" }); +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index d5e6bab..3120081 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,15 +1,19 @@ -export type { AuthUser, CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types"; +export type { AuthSession, AuthUser, CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types"; export { ApiError, getApiErrorMessage } from "./errors"; export type { BackendMetaResponse } from "./health"; export { getBackendMeta } from "./health"; export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races"; export { forgotPassword, + changePassword, getCurrentUser, + getSessions, login, logout, register, resendVerification, resetPassword, + revokeOtherSessions, + revokeSession, verifyEmail, } from "./auth"; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 79ba65d..906ada5 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -49,3 +49,11 @@ export interface AuthUser { email: string; emailVerifiedAt: string | null; } + +export interface AuthSession { + id: string; + createdAt: string; + lastSeenAt: string; + expiresAt: string; + current: boolean; +} diff --git a/frontend/src/app/layouts/AppLayout.tsx b/frontend/src/app/layouts/AppLayout.tsx index d99ba4c..e3ed9d8 100644 --- a/frontend/src/app/layouts/AppLayout.tsx +++ b/frontend/src/app/layouts/AppLayout.tsx @@ -38,9 +38,19 @@ export function AppLayout(): JSX.Element { + Добавить {user ? ( - + <> + + isActive ? "app-shell__link app-shell__link--active" : "app-shell__link" + } + > + Аккаунт + + + ) : ( , children: [ { index: true, element: }, + { path: "account", element: }, { path: "races", element: }, { path: "races/new", element: }, { path: "races/day/:ymd", element: }, diff --git a/frontend/src/components/PaceTrendChart.tsx b/frontend/src/components/PaceTrendChart.tsx index f696305..3d8a508 100644 --- a/frontend/src/components/PaceTrendChart.tsx +++ b/frontend/src/components/PaceTrendChart.tsx @@ -1,92 +1,107 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; import type { Race } from "../api"; -import { formatRaceDate, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } from "../lib"; +import { formatRaceDate, getPaceLabel, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } from "../lib"; type PaceTrendChartProps = { races: Race[]; distanceKm: number; }; -/** Линейный график: время финиша (минуты) по завершённым стартам выбранной дистанции. */ -export function PaceTrendChart(props: PaceTrendChartProps): JSX.Element { - const { races, distanceKm } = props; +function formatAxisTime(minutes: number): string { + const totalSeconds = Math.round(minutes * 60); + const hours = Math.floor(totalSeconds / 3600); + const rest = totalSeconds % 3600; + return hours ? `${hours}:${String(Math.floor(rest / 60)).padStart(2, "0")}` : `${Math.floor(rest / 60)}:${String(rest % 60).padStart(2, "0")}`; +} +function formatShortDate(date: string): string { + return parseRaceDate(date).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }); +} + +/** Линейный график: время финиша по завершённым стартам выбранной дистанции. */ +export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.Element { + const navigate = useNavigate(); + const [activeId, setActiveId] = useState(null); const series = races - .filter( - (race) => - race.status === "completed" && - isCloseDistance(race.distanceKm, distanceKm) && - parseFinishTimeToSeconds(race.finishTime) != null, - ) - .sort( - (a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime(), - ) - .map((race) => { - const seconds = parseFinishTimeToSeconds(race.finishTime)!; - return { race, minutes: seconds / 60 }; - }); + .filter((race) => race.status === "completed" && isCloseDistance(race.distanceKm, distanceKm) && parseFinishTimeToSeconds(race.finishTime) != null) + .sort((a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime()) + .map((race) => ({ race, minutes: parseFinishTimeToSeconds(race.finishTime)! / 60 })); if (series.length < 2) { - return ( -

- Нужно минимум два завершённых старта с временем на выбранной дистанции. -

- ); + return

Нужно минимум два завершённых старта с временем на выбранной дистанции.

; } - const minutes = series.map((s) => s.minutes); - const minM = Math.min(...minutes); - const maxM = Math.max(...minutes); - const range = maxM - minM || 1; - const n = series.length; - - const pad = 4; + const values = series.map((item) => item.minutes); + const min = Math.min(...values); + const max = Math.max(...values); + const range = max - min || 1; const w = 100; - const h = 36; - const innerW = w - pad * 2; - const innerH = h - pad * 2; - - const points = series - .map((s, i) => { - const x = pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW); - const norm = (maxM - s.minutes) / range; - const y = pad + (1 - norm) * innerH; - return `${x},${y}`; - }) - .join(" "); - - const last = series[series.length - 1]!; - const best = series.reduce((currentBest, item) => (item.minutes < currentBest.minutes ? item : currentBest), series[0]!); - const dotPoints = series.map((s, i) => { - const x = pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW); - const norm = (maxM - s.minutes) / range; - const y = pad + (1 - norm) * innerH; - return { x, y, id: s.race.id }; + const h = 58; + const margin = { top: 6, right: 4, bottom: 14, left: 17 }; + const innerW = w - margin.left - margin.right; + const innerH = h - margin.top - margin.bottom; + const pointAt = (item: (typeof series)[number], index: number) => ({ + x: margin.left + (index / (series.length - 1)) * innerW, + y: margin.top + ((max - item.minutes) / range) * innerH, }); + const points = series.map(pointAt); + const line = points.map(({ x, y }) => `${x},${y}`).join(" "); + const yTicks = [max, (max + min) / 2, min]; + const labelStep = Math.max(1, Math.ceil(series.length / 6)); + const active = series.find((item) => item.race.id === activeId) ?? null; return (
- - - - - {dotPoints.map((point, index) => ( - - ))} + + Время + {yTicks.map((tick) => { + const y = margin.top + ((max - tick) / range) * innerH; + return ( + + + {formatAxisTime(tick)} + + ); + })} + + + + {series.map((item, index) => { + const point = points[index]!; + const isLabeled = index % labelStep === 0 || index === series.length - 1; + return ( + setActiveId(item.race.id)} + onMouseLeave={() => setActiveId(null)} + onFocus={() => setActiveId(item.race.id)} + onBlur={() => setActiveId(null)} + onClick={() => navigate(`/races/${item.race.id}`)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + navigate(`/races/${item.race.id}`); + } + }} + > + + {isLabeled ? {formatShortDate(item.race.date)} : null} + + ); + })} + Дата старта -
-

- Последний: {formatRaceDate(last.race.date)} · {last.race.finishTime} · {last.minutes.toFixed(1)} мин -

-

- Лучший: {formatRaceDate(best.race.date)} · {best.race.finishTime} · {best.minutes.toFixed(1)} мин -

-
+ {active ? ( +
+ {active.race.title} + {formatRaceDate(active.race.date)} · {active.race.finishTime} · {getPaceLabel(active.race.finishTime, active.race.distanceKm)} +
+ ) :

Наведите на точку или выберите её клавиатурой.

}
); } diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx new file mode 100644 index 0000000..0b99fc4 --- /dev/null +++ b/frontend/src/pages/AccountPage.tsx @@ -0,0 +1,131 @@ +import { useCallback, useEffect, useState } from "react"; +import { ApiError, changePassword, getSessions, revokeOtherSessions, revokeSession } from "../api"; +import type { AuthSession } from "../api"; +import { useAuth } from "../app/auth/AuthContext"; + +function formatDate(value: string): string { + return new Intl.DateTimeFormat("ru-RU", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); +} + +export function AccountPage(): JSX.Element { + const { user, logout } = useAuth(); + const [sessions, setSessions] = useState([]); + const [sessionsError, setSessionsError] = useState(""); + const [sessionsLoading, setSessionsLoading] = useState(true); + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [passwordConfirmation, setPasswordConfirmation] = useState(""); + const [passwordError, setPasswordError] = useState(""); + const [passwordSuccess, setPasswordSuccess] = useState(""); + const [passwordSaving, setPasswordSaving] = useState(false); + + const loadSessions = useCallback(async () => { + setSessionsLoading(true); + setSessionsError(""); + try { + setSessions(await getSessions()); + } catch (error) { + setSessionsError(error instanceof ApiError ? error.message : "Не удалось загрузить активные сессии."); + } finally { + setSessionsLoading(false); + } + }, []); + + useEffect(() => { + void loadSessions(); + }, [loadSessions]); + + async function handlePasswordSubmit(event: React.FormEvent): Promise { + event.preventDefault(); + setPasswordError(""); + setPasswordSuccess(""); + if (newPassword !== passwordConfirmation) { + setPasswordError("Новый пароль и его повтор не совпадают."); + return; + } + + setPasswordSaving(true); + try { + await changePassword({ currentPassword, newPassword }); + setCurrentPassword(""); + setNewPassword(""); + setPasswordConfirmation(""); + setPasswordSuccess("Пароль изменён. Остальные активные сессии завершены."); + await loadSessions(); + } catch (error) { + setPasswordError(error instanceof ApiError ? error.message : "Не удалось изменить пароль."); + } finally { + setPasswordSaving(false); + } + } + + async function handleSessionRevoke(session: AuthSession): Promise { + try { + await revokeSession(session.id); + if (session.current) { + await logout(); + return; + } + await loadSessions(); + } catch (error) { + setSessionsError(error instanceof ApiError ? error.message : "Не удалось завершить сессию."); + } + } + + async function handleOtherSessionsRevoke(): Promise { + try { + await revokeOtherSessions(); + await loadSessions(); + } catch (error) { + setSessionsError(error instanceof ApiError ? error.message : "Не удалось завершить сессии."); + } + } + + return ( +
+

Аккаунт

+

{user?.email} — email подтверждён.

+ +
void handlePasswordSubmit(event)}> +

Сменить пароль

+ + + + {passwordError ?

{passwordError}

: null} + {passwordSuccess ?

{passwordSuccess}

: null} + +
+ +
+

Активные сессии

+

После смены пароля все остальные сессии завершаются автоматически.

+ {sessionsError ?

{sessionsError}

: null} + {sessionsLoading ?

Загружаем сессии…

: null} + {!sessionsLoading ? ( +
    + {sessions.map((session) => ( +
  • +
    + {session.current ? "Текущая сессия" : "Активная сессия"} +

    Последняя активность: {formatDate(session.lastSeenAt)}

    +

    Создана: {formatDate(session.createdAt)}

    +
    + {!session.current ? : null} +
  • + ))} +
+ ) : null} + {sessions.some((session) => !session.current) ? : null} +
+
+ ); +} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 13fdec7..4a0b6f7 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,6 +1,6 @@ import type { CSSProperties } from "react"; import { useEffect, useMemo, useState } from "react"; -import { Link } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import type { Race } from "../api"; import { ApiError, getRaces } from "../api"; import { PaceTrendChart } from "../components"; @@ -35,10 +35,14 @@ function toCssUrl(value: string): string { } export function DashboardPage(): JSX.Element { + const navigate = useNavigate(); const [races, setRaces] = useState([]); const [isLoading, setIsLoading] = useState(true); const [errorMessage, setErrorMessage] = useState(null); const [chartDistanceKm, setChartDistanceKm] = useState(10); + const [latestDistance, setLatestDistance] = useState(""); + const [latestYear, setLatestYear] = useState(""); + const [latestPage, setLatestPage] = useState(0); useEffect(() => { const ac = new AbortController(); @@ -130,6 +134,7 @@ export function DashboardPage(): JSX.Element { .filter((race) => race.status === "completed") .map((race) => ({ id: race.id, + date: race.date, year: parseRaceDate(race.date).getFullYear(), title: race.title, distance: formatDistance(race.distanceKm), @@ -137,9 +142,20 @@ export function DashboardPage(): JSX.Element { pace: getPaceLabel(race.finishTime, race.distanceKm) ?? "не удалось вычислить", place: race.finishPlace?.trim() ? race.finishPlace : "нет данных", })) - .sort((left, right) => right.year - left.year || left.title.localeCompare(right.title, "ru-RU")); + .sort((left, right) => parseRaceDate(right.date).getTime() - parseRaceDate(left.date).getTime()); }, [races]); + const latestDistances = useMemo( + () => [...new Set(comparisonRows.map((row) => row.distance))].sort((left, right) => Number(left.split(" ")[0]) - Number(right.split(" ")[0])), + [comparisonRows], + ); + const latestYears = useMemo(() => [...new Set(comparisonRows.map((row) => row.year))].sort((a, b) => b - a), [comparisonRows]); + const filteredLatestRows = comparisonRows.filter( + (row) => (!latestDistance || row.distance === latestDistance) && (!latestYear || row.year === Number(latestYear)), + ); + const latestPageCount = Math.ceil(filteredLatestRows.length / 5); + const latestRows = filteredLatestRows.slice(latestPage * 5, latestPage * 5 + 5); + const seasonProgress = dashboardMetrics.seasonTotal > 0 ? Math.round((dashboardMetrics.seasonCompletedCount / dashboardMetrics.seasonTotal) * 100) @@ -316,26 +332,50 @@ export function DashboardPage(): JSX.Element {

Рекорды по дистанциям

{personalRecordsByDistance.map((item) => ( -
-

{formatDistance(item.distanceKm)}

+
{item.bestRace ? ( - <> + +

{formatDistance(item.distanceKm)}

{item.bestRace.finishTime ?? "время не указано"}

{item.bestRace.title}

{formatRaceDate(item.bestRace.date)}

- + ) : ( -

Нет завершённых стартов для этой дистанции.

+ <> +

{formatDistance(item.distanceKm)}

+

Нет завершённых стартов для этой дистанции.

+ )}
))}
-
-

Сравнение стартов

+
+

Последние старты

{comparisonRows.length > 0 ? ( -
+ <> +
+ + +
+
@@ -348,8 +388,20 @@ export function DashboardPage(): JSX.Element { - {comparisonRows.map((row) => ( - + {latestRows.map((row) => ( + navigate(`/races/${row.id}`)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + navigate(`/races/${row.id}`); + } + }} + aria-label={`Открыть старт: ${row.title}`} + > @@ -360,7 +412,16 @@ export function DashboardPage(): JSX.Element { ))}
{row.year} {row.title} {row.distance}
-
+
+ {filteredLatestRows.length === 0 ?

По выбранным фильтрам стартов нет.

: null} + {latestPageCount > 1 ? ( + + ) : null} + ) : (

Нет завершённых стартов для сравнения.

)} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 007ae7d..4d9eccd 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -177,6 +177,35 @@ a { gap: var(--space-4); } +.account__success { + margin: 0; + color: var(--color-success); +} + +.account__sessions { + display: grid; + gap: var(--space-3); + margin: 0; + padding: 0; + list-style: none; +} + +.account__session { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} + +.account__session p { + margin: var(--space-1) 0 0; + color: var(--color-text-muted); + font-size: var(--font-size-caption); +} + .dashboard-grid { margin-top: var(--space-6); display: grid; @@ -472,6 +501,32 @@ a { min-width: 10rem; } +.comparison-table__row { + cursor: pointer; + transition: background-color 0.15s ease, transform 0.15s ease; +} + +.comparison-table__row:hover, +.comparison-table__row:focus-visible { + outline: none; + background: color-mix(in srgb, var(--color-accent) 9%, transparent); + transform: translateX(0.2rem); +} + +.dashboard-latest__filters, +.dashboard-latest__pagination { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: var(--space-3); + margin: 0 0 var(--space-4); +} + +.dashboard-latest__pagination { + align-items: center; + margin-top: var(--space-4); +} + .races-filter__label { font-size: var(--font-size-caption); font-weight: 600; @@ -508,6 +563,57 @@ a { vector-effect: non-scaling-stroke; } +.pace-chart__axis { + stroke: rgba(14, 31, 45, 0.42); + stroke-width: 0.75; + vector-effect: non-scaling-stroke; +} + +.pace-chart__axis-label, +.pace-chart__axis-title, +.pace-chart__point-label { + fill: var(--color-text-muted); + font-size: 2.5px; +} + +.pace-chart__axis-title { + font-size: 2.8px; + font-weight: 700; +} + +.pace-chart__point-label { + font-size: 2.25px; +} + +.pace-chart__point { + cursor: pointer; + outline: none; +} + +.pace-chart__point:focus-visible .pace-chart__dot, +.pace-chart__point:hover .pace-chart__dot { + stroke-width: 2; +} + +.pace-chart__tooltip, +.pace-chart__hint { + margin: var(--space-3) 0 0; + font-size: var(--font-size-caption); +} + +.pace-chart__tooltip { + display: grid; + gap: var(--space-1); + padding: var(--space-3); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.78); + color: var(--color-text); +} + +.pace-chart__hint { + color: var(--color-text-muted); +} + .pace-chart__caption { margin: var(--space-3) 0 0; font-size: var(--font-size-caption); @@ -2039,3 +2145,14 @@ body { grid-template-columns: 1fr; } } + +@media (prefers-reduced-motion: reduce) { + .comparison-table__row { + transition: none; + } + + .comparison-table__row:hover, + .comparison-table__row:focus-visible { + transform: none; + } +}