diff --git a/.env.example b/.env.example index 1b78408..5147ff7 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,8 @@ API_PORT=3001 CORS_ORIGIN=http://localhost:5173 # ─── Auth / sessions ───────────────────────────────────────── -# APP_BASE_URL is the only source for verify/reset email links. +# Базовый URL ссылок подтверждения и сброса пароля. В production по умолчанию +# используется https://run.vakanaut.ru; задайте URL явно для другого домена. APP_BASE_URL=http://localhost:5173 SESSION_SECRET=replace_with_32plus_char_random_secret # Production defaults to __Host-sid + Secure cookies. Local dev can stay insecure over http. diff --git a/CHANGELOG.md b/CHANGELOG.md index a637830..94ed878 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,30 @@ Все заметки о выпусках Calendar Run. Backend и frontend имеют независимые версии. Дата означает дату попадания изменения в `main`; она не заменяет дату production-деплоя. -Планируемые версии не вносятся сюда до выпуска — они находятся в [docs/product-backlog.md](docs/product-backlog.md). +Локально подготовленные версии помечаются явно; дату `main` нужно указать при +интеграции. Планируемые версии находятся в [docs/product-backlog.md](docs/product-backlog.md). ## Backend +### [1.7.1] — подготовлено локально, 2026-09-24 + +#### Fixed + +- Ошибки отправки писем в админских действиях больше не оставляют изменения и токены в базе данных. +- Редактирование неизменённого email не перевыпускает ссылку подтверждения. +- Production-ссылки для подтверждения email и сброса пароля по умолчанию ведут на `https://run.vakanaut.ru`. + +### [1.7.0] — подготовлено локально, 2026-09-24 + +#### Added + +- Админ API для просмотра пользователей, дат регистрации и входа, статистики забегов и управления аккаунтами. +- Ограничение админ-доступа на сервере; ссылки подтверждения и сброса пароля отправляются через настроенный SMTP без записи токенов в логи. + +#### Changed + +- Дата последнего входа сохраняется отдельно и заполняется для существующих аккаунтов по доступным данным сессий. + ### [1.6.0] — 2026-07-13 #### Added @@ -108,6 +128,18 @@ ## Frontend +### [0.11.1] — подготовлено локально, 2026-09-24 + +#### Fixed + +- PATCH админки не отправляет email без изменений; ошибки API показываются рядом с открытой панелью. + +### [0.11.0] — подготовлено локально, 2026-09-24 + +#### Added + +- Страница администрирования пользователей со статистикой забегов и действиями управления аккаунтом. + ### [0.10.2] — 2026-08-14 #### Fixed diff --git a/backend/migrations/008_user_last_login.sql b/backend/migrations/008_user_last_login.sql new file mode 100644 index 0000000..71494eb --- /dev/null +++ b/backend/migrations/008_user_last_login.sql @@ -0,0 +1,10 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; + +UPDATE users u +SET last_login_at = latest.created_at +FROM ( + SELECT user_id, MAX(created_at) AS created_at + FROM sessions + GROUP BY user_id +) latest +WHERE u.id = latest.user_id AND u.last_login_at IS NULL; diff --git a/backend/package-lock.json b/backend/package-lock.json index dd2fdc3..5a1355f 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-backend", - "version": "1.6.0", + "version": "1.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-backend", - "version": "1.6.0", + "version": "1.7.0", "dependencies": { "argon2": "^0.44.0", "cookie-parser": "^1.4.7", diff --git a/backend/package.json b/backend/package.json index cdfc713..7618cb8 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "calendar-run-backend", - "version": "1.6.0", + "version": "1.7.1", "private": true, "scripts": { "build": "tsc", @@ -9,7 +9,7 @@ "db:migrate": "ts-node src/migrate.ts", "seed": "ts-node src/seed.ts", "backfill:covers": "ts-node src/backfillRaceCovers.ts", - "test": "tsx --import ./test/setup.ts --test test/app.test.ts" + "test": "tsx --import ./test/setup.mts --test test/app.test.ts" }, "dependencies": { "argon2": "^0.44.0", diff --git a/backend/src/app.ts b/backend/src/app.ts index b08ce71..41f82da 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -8,6 +8,7 @@ import authRouter from "./routes/auth"; import healthRouter from "./routes/health"; import racesRouter from "./routes/races"; import usersRouter from "./routes/users"; +import adminRouter from "./routes/admin"; const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com"; @@ -56,6 +57,7 @@ export function createApp(): express.Express { app.use("/api", healthRouter); app.use("/api", authRouter); app.use("/api", usersRouter); + app.use("/api", adminRouter); app.use("/api", racesRouter); app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { diff --git a/backend/src/authMiddleware.ts b/backend/src/authMiddleware.ts index 60b1fed..25b310c 100644 --- a/backend/src/authMiddleware.ts +++ b/backend/src/authMiddleware.ts @@ -13,6 +13,7 @@ declare global { profileUsername: string | null; isFutureRacesPublic: boolean; isCompletedRacesPublic: boolean; + isAdmin: boolean; }; csrfTokenHash: string; sessionToken: string; @@ -72,6 +73,16 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo next(); } +export function requireAdmin(req: Request, res: Response, next: NextFunction): void { + requireAuth(req, res, () => { + if (!req.auth!.user.isAdmin) { + res.status(403).json({ error: "forbidden", details: ["Administrator access required"] }); + return; + } + next(); + }); +} + export function requireCsrf(req: Request, res: Response, next: NextFunction): void { if (!MUTATING_METHODS.has(req.method) || !req.auth) { next(); diff --git a/backend/src/authService.ts b/backend/src/authService.ts index a9882ef..9244674 100644 --- a/backend/src/authService.ts +++ b/backend/src/authService.ts @@ -6,6 +6,7 @@ import { addDays, addHours, hashPassword, + isAdminEmail, normalizeEmail, randomToken, sha256Hex, @@ -21,6 +22,7 @@ export interface AuthUser { profileUsername: string | null; isFutureRacesPublic: boolean; isCompletedRacesPublic: boolean; + isAdmin: boolean; } interface UserRow { @@ -77,6 +79,7 @@ function userFromRow(row: UserRow): AuthUser { profileUsername: row.profile_username, isFutureRacesPublic: row.is_future_races_public, isCompletedRacesPublic: row.is_completed_races_public, + isAdmin: isAdminEmail(row.email), }; } @@ -190,18 +193,30 @@ export async function createSession(userId: string): Promise<{ sessionToken: str const sessionToken = randomToken(32); const csrfToken = randomToken(32); const expiresAt = addDays(new Date(), config.session.ttlDays); - const { rows } = await pool.query( - `INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) - VALUES ($1, $2, $3, $4) - RETURNING id, user_id, token_hash, csrf_token_hash, expires_at, - (SELECT email FROM users WHERE id = $1) AS email, - (SELECT email_verified_at FROM users WHERE id = $1) AS email_verified_at, - (SELECT profile_username FROM users WHERE id = $1) AS profile_username, - (SELECT is_future_races_public FROM users WHERE id = $1) AS is_future_races_public, - (SELECT is_completed_races_public FROM users WHERE id = $1) AS is_completed_races_public`, - [userId, sha256Hex(sessionToken), sha256Hex(csrfToken), expiresAt], - ); - const row = rows[0]; + const client = await pool.connect(); + let row: SessionRow; + try { + await client.query("BEGIN"); + await client.query("UPDATE users SET last_login_at = NOW() WHERE id = $1", [userId]); + const { rows } = await client.query( + `INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING id, user_id, token_hash, csrf_token_hash, expires_at, + (SELECT email FROM users WHERE id = $1) AS email, + (SELECT email_verified_at FROM users WHERE id = $1) AS email_verified_at, + (SELECT profile_username FROM users WHERE id = $1) AS profile_username, + (SELECT is_future_races_public FROM users WHERE id = $1) AS is_future_races_public, + (SELECT is_completed_races_public FROM users WHERE id = $1) AS is_completed_races_public`, + [userId, sha256Hex(sessionToken), sha256Hex(csrfToken), expiresAt], + ); + row = rows[0]; + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } securityLog("session.issued", { userId }); return { sessionToken, @@ -213,6 +228,7 @@ export async function createSession(userId: string): Promise<{ sessionToken: str profileUsername: row.profile_username, isFutureRacesPublic: row.is_future_races_public, isCompletedRacesPublic: row.is_completed_races_public, + isAdmin: isAdminEmail(row.email), }, }; } @@ -350,6 +366,7 @@ export async function getSession(sessionToken: string): Promise<{ user: AuthUser profileUsername: row.profile_username, isFutureRacesPublic: row.is_future_races_public, isCompletedRacesPublic: row.is_completed_races_public, + isAdmin: isAdminEmail(row.email), }, csrfTokenHash: row.csrf_token_hash, }; diff --git a/backend/src/config.ts b/backend/src/config.ts index 2665032..c57098e 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -36,6 +36,15 @@ export function resolveTurnstileBypassToken(params: { return raw; } +export function requireSmtpInProduction(params: { securityProfile: string; smtpHost?: string | null }): void { + if (params.securityProfile === "production" && !params.smtpHost) { + throw new Error("SMTP_HOST is required in production"); + } +} + +const smtpHost = optionalEnv("SMTP_HOST"); +requireSmtpInProduction({ securityProfile, smtpHost }); + export const config = { db: { host: requireEnv("DB_HOST"), @@ -47,7 +56,7 @@ export const config = { apiPort: parseInt(process.env.PORT || process.env.API_PORT || "3001", 10), /** Одно значение или несколько через запятую (прод: https://домен) */ corsOrigin: parseCorsOrigins(), - appBaseUrl: process.env.APP_BASE_URL?.trim() || "http://localhost:5173", + appBaseUrl: process.env.APP_BASE_URL?.trim() || (securityProfile === "production" ? "https://run.vakanaut.ru" : "http://localhost:5173"), session: { secret: requireEnv("SESSION_SECRET"), cookieName: @@ -57,7 +66,7 @@ export const config = { ttlDays: parseInt(process.env.SESSION_TTL_DAYS || "30", 10), }, smtp: { - host: optionalEnv("SMTP_HOST"), + host: smtpHost, port: parseInt(process.env.SMTP_PORT || "587", 10), secure: parseBoolean(process.env.SMTP_SECURE, false), user: optionalEnv("SMTP_USER"), diff --git a/backend/src/mailer.ts b/backend/src/mailer.ts index e6ea265..39b4856 100644 --- a/backend/src/mailer.ts +++ b/backend/src/mailer.ts @@ -3,7 +3,10 @@ import { config } from "./config"; export async function sendMail(to: string, subject: string, text: string): Promise { if (!config.smtp.host) { - console.info("[mail] SMTP is not configured; email content follows.", { to, subject, text }); + if (config.securityProfile === "production") { + throw new Error("SMTP is required in production to send email"); + } + console.info("[mail] SMTP is not configured; email skipped.", { to, subject }); return; } diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..c3339f4 --- /dev/null +++ b/backend/src/routes/admin.ts @@ -0,0 +1,208 @@ +import { Request, Response, Router } from "express"; +import { PoolClient } from "pg"; +import rateLimit from "express-rate-limit"; +import { z } from "zod"; +import { createResetToken, createVerificationToken, sendResetEmail, sendVerificationEmail } from "../authService"; +import { requireAdmin } from "../authMiddleware"; +import { pool } from "../db"; +import { ADMIN_EMAIL, normalizeEmail } from "../security"; +import { securityLog } from "../securityLog"; + +const router: Router = Router(); +router.use("/admin", requireAdmin); + +const userIdSchema = z.string().uuid(); +const profileUsernameSchema = z.string().trim().toLowerCase().min(3).max(30) + .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/); +const updateUserSchema = z.object({ + email: z.string().trim().email().optional(), + profileUsername: profileUsernameSchema.nullable().optional(), + isFutureRacesPublic: z.boolean().optional(), + isCompletedRacesPublic: z.boolean().optional(), +}).strict().refine((body) => Object.keys(body).length > 0); + +const resetLimiter = rateLimit({ + windowMs: 60 * 60 * 1000, + limit: 10, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: (req) => `admin-reset:${req.auth!.user.id}`, +}); + +function validationError(res: Response): void { + res.status(400).json({ error: "validation_error", details: ["Invalid request"] }); +} + +function userId(req: Request, res: Response): string | null { + const parsed = userIdSchema.safeParse(req.params.id); + if (!parsed.success) { + validationError(res); + return null; + } + return parsed.data; +} + +router.get("/admin/users", async (_req: Request, res: Response, next) => { + try { + const { rows } = await pool.query(` + SELECT u.id, u.email, u.email_verified_at, u.profile_username, + u.is_future_races_public, u.is_completed_races_public, + u.created_at, u.last_login_at, + COUNT(r.id)::int AS races_count, + COUNT(r.id) FILTER (WHERE r.status = 'completed')::int AS completed_races_count + FROM users u + LEFT JOIN races r ON r.owner_user_id = u.id + GROUP BY u.id + ORDER BY u.created_at ASC, u.id ASC + `); + res.json(rows.map((row) => ({ + id: row.id, + email: row.email, + emailVerifiedAt: row.email_verified_at, + profileUsername: row.profile_username, + isFutureRacesPublic: row.is_future_races_public, + isCompletedRacesPublic: row.is_completed_races_public, + createdAt: row.created_at, + lastLoginAt: row.last_login_at, + racesCount: row.races_count, + completedRacesCount: row.completed_races_count, + }))); + } catch (error) { + next(error); + } +}); + +router.patch("/admin/users/:id", async (req: Request, res: Response, next) => { + const id = userId(req, res); + const parsed = updateUserSchema.safeParse(req.body); + if (!id || !parsed.success) { + if (id && !parsed.success) validationError(res); + return; + } + + let client: PoolClient | undefined; + let verification: { email: string; token: string } | null = null; + try { + client = await pool.connect(); + await client.query("BEGIN"); + const currentResult = await client.query<{ email: string; email_verified_at: Date | string | null }>( + "SELECT email, email_verified_at FROM users WHERE id = $1 FOR UPDATE", + [id], + ); + const current = currentResult.rows[0]; + if (!current) { + await client.query("ROLLBACK").catch(() => undefined); + res.status(404).json({ error: "not_found", details: ["User not found"] }); + return; + } + + const currentEmail = normalizeEmail(current.email); + const nextEmail = parsed.data.email === undefined ? currentEmail : normalizeEmail(parsed.data.email); + const emailChanged = nextEmail !== currentEmail; + if ((currentEmail === ADMIN_EMAIL && emailChanged) || (currentEmail !== ADMIN_EMAIL && nextEmail === ADMIN_EMAIL)) { + await client.query("ROLLBACK").catch(() => undefined); + res.status(409).json({ error: "protected_admin", details: ["Administrator identity is protected"] }); + return; + } + + await client.query( + `UPDATE users SET + email = CASE WHEN $2 THEN $3 ELSE email END, + email_verified_at = CASE WHEN $2 THEN NULL ELSE email_verified_at END, + profile_username = CASE WHEN $4 THEN $5 ELSE profile_username END, + is_future_races_public = CASE WHEN $6 THEN $7 ELSE is_future_races_public END, + is_completed_races_public = CASE WHEN $8 THEN $9 ELSE is_completed_races_public END, + updated_at = NOW() + WHERE id = $1`, + [ + id, + emailChanged, + nextEmail, + parsed.data.profileUsername !== undefined, + parsed.data.profileUsername, + parsed.data.isFutureRacesPublic !== undefined, + parsed.data.isFutureRacesPublic, + parsed.data.isCompletedRacesPublic !== undefined, + parsed.data.isCompletedRacesPublic, + ], + ); + + if (emailChanged) { + await client.query("UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", [id]); + await client.query("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", [id]); + verification = { email: nextEmail, token: await createVerificationToken(client, id) }; + } + if (verification) await sendVerificationEmail(verification.email, verification.token); + await client.query("COMMIT"); + } catch (error) { + if (client) await client.query("ROLLBACK").catch(() => undefined); + if (typeof error === "object" && error !== null && (error as { code?: string }).code === "23505") { + res.status(409).json({ error: "conflict", details: ["Email or username is already in use"] }); + return; + } + next(error); + return; + } finally { + client?.release(); + } + + securityLog("admin.user_updated", { actorUserId: req.auth!.user.id, targetUserId: id }); + res.status(204).end(); +}); + +router.post("/admin/users/:id/password-reset", resetLimiter, async (req: Request, res: Response, next) => { + const id = userId(req, res); + if (!id) return; + + let client: PoolClient | undefined; + let email: string; + let token: string; + try { + client = await pool.connect(); + await client.query("BEGIN"); + const result = await client.query<{ email: string }>("SELECT email FROM users WHERE id = $1 FOR UPDATE", [id]); + if (!result.rows[0]) { + await client.query("ROLLBACK").catch(() => undefined); + res.status(404).json({ error: "not_found", details: ["User not found"] }); + return; + } + email = result.rows[0].email; + token = await createResetToken(client, id); + await sendResetEmail(email, token); + await client.query("COMMIT"); + } catch (error) { + if (client) await client.query("ROLLBACK").catch(() => undefined); + next(error); + return; + } finally { + client?.release(); + } + + securityLog("admin.password_reset_requested", { actorUserId: req.auth!.user.id, targetUserId: id }); + res.status(202).json({ ok: true }); +}); + +router.delete("/admin/users/:id", async (req: Request, res: Response, next) => { + const id = userId(req, res); + if (!id) return; + if (id === req.auth!.user.id) { + res.status(409).json({ error: "protected_admin", details: ["Administrator account cannot be deleted"] }); + return; + } + try { + const result = await pool.query( + "DELETE FROM users WHERE id = $1 AND LOWER(BTRIM(email)) <> $2 RETURNING id", + [id, ADMIN_EMAIL], + ); + if (!result.rows[0]) { + res.status(404).json({ error: "not_found", details: ["User not found"] }); + return; + } + securityLog("admin.user_deleted", { actorUserId: req.auth!.user.id, targetUserId: id }); + res.status(204).end(); + } catch (error) { + next(error); + } +}); + +export default router; diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 1698a0b..432d869 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -26,7 +26,13 @@ const genericOk = { ok: true }; const genericAuthError = { error: "invalid_credentials", details: ["Invalid email or password"] }; const registerLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 10, standardHeaders: true, legacyHeaders: false }); -const loginLimiter = rateLimit({ windowMs: 60 * 1000, limit: 20, standardHeaders: true, legacyHeaders: false }); +const loginLimiter = rateLimit({ + windowMs: 60 * 1000, + limit: 20, + standardHeaders: true, + legacyHeaders: false, + skip: () => process.env.NODE_ENV === "test", +}); const loginEmailLimiter = rateLimit({ windowMs: 60 * 1000, limit: 5, @@ -47,7 +53,7 @@ const passwordChangeLimiter = rateLimit({ limit: 5, standardHeaders: true, legacyHeaders: false, - keyGenerator: (req) => `password:${req.auth?.user.id ?? req.ip}`, + keyGenerator: (req) => `password:${req.auth!.user.id}`, }); const registerSchema = z.object({ diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index f8bb2f1..fc4ee25 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -15,7 +15,10 @@ interface PublicRaceRow { } function raceDate(value: string | Date): string { - return typeof value === "string" ? value.slice(0, 10) : value.toISOString().slice(0, 10); + if (typeof value === "string") return value.slice(0, 10); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + return `${value.getFullYear()}-${month}-${day}`; } router.get("/users/:username/races", async (req: Request, res: Response) => { diff --git a/backend/src/security.ts b/backend/src/security.ts index a50271f..52e4377 100644 --- a/backend/src/security.ts +++ b/backend/src/security.ts @@ -3,6 +3,7 @@ import argon2 from "argon2"; export const PASSWORD_MIN_LENGTH = 15; export const PASSWORD_MAX_LENGTH = 256; +export const ADMIN_EMAIL = "av_sports@mail.ru"; export const ARGON2_OPTIONS: argon2.Options & { raw?: false } = { type: argon2.argon2id, @@ -19,6 +20,10 @@ export function normalizeEmail(value: string): string { return value.trim().toLowerCase(); } +export function isAdminEmail(value: string): boolean { + return normalizeEmail(value) === ADMIN_EMAIL; +} + export function isValidPassword(value: string): boolean { return value.length >= PASSWORD_MIN_LENGTH && value.length <= PASSWORD_MAX_LENGTH; } diff --git a/backend/test/app.test.ts b/backend/test/app.test.ts index 23bcca5..58d937a 100644 --- a/backend/test/app.test.ts +++ b/backend/test/app.test.ts @@ -12,7 +12,7 @@ import { resetPassword, verifyEmailToken, } from "../src/authService"; -import { resolveTurnstileBypassToken } from "../src/config"; +import { requireSmtpInProduction, resolveTurnstileBypassToken } from "../src/config"; import { pool } from "../src/db"; import { extractRaceCoverImage, extractRaceCoverImageFromHtml } from "../src/raceCoverImage"; import { hashPassword, normalizeEmail } from "../src/security"; @@ -116,6 +116,11 @@ test("production config rejects Turnstile bypass token", () => { ); }); +test("production config requires SMTP for account email delivery", () => { + assert.throws(() => requireSmtpInProduction({ securityProfile: "production", smtpHost: null }), /SMTP_HOST/); + assert.doesNotThrow(() => requireSmtpInProduction({ securityProfile: "development", smtpHost: null })); +}); + test("production CSP allows Turnstile without unsafe script directives", () => { const options = buildHelmetOptions("production") as any; const directives = options.contentSecurityPolicy.directives; @@ -276,6 +281,167 @@ test("login uses generic response for missing user and wrong password", async () assert.deepEqual(missingUser.body, wrongPassword.body); }); +test("admin user management is restricted and covers list, reset, edit and cascade delete", async () => { + const password = "correct horse battery staple"; + const adminId = await createVerifiedUser("av_sports@mail.ru", password); + const targetId = await createVerifiedUser("admin-target@example.com", password); + const duplicateId = await createVerifiedUser("admin-duplicate@example.com", password); + const unverifiedId = await createUnverifiedUser("admin-unverified@example.com", password); + const admin = await loginAgent("av_sports@mail.ru", password); + const target = await loginAgent("admin-target@example.com", password); + + const adminMe = await admin.agent.get("/api/auth/me").expect(200); + assert.equal(adminMe.body.user.isAdmin, true); + const adminCsrfToken = adminMe.body.csrfToken as string; + const targetMe = await target.agent.get("/api/auth/me").expect(200); + assert.equal(targetMe.body.user.isAdmin, false); + const targetCsrfToken = targetMe.body.csrfToken as string; + + await request(app).get("/api/admin/users").expect(401); + await target.agent.get("/api/admin/users").expect(403); + await target.agent + .patch(`/api/admin/users/${duplicateId}`) + .set("X-CSRF-Token", targetCsrfToken) + .send({ profileUsername: "forbidden-edit" }) + .expect(403); + await target.agent + .post(`/api/admin/users/${duplicateId}/password-reset`) + .set("X-CSRF-Token", targetCsrfToken) + .expect(403); + await target.agent + .delete(`/api/admin/users/${duplicateId}`) + .set("X-CSRF-Token", targetCsrfToken) + .expect(403); + + await target.agent.post("/api/races").set("X-CSRF-Token", targetCsrfToken).send({ + slug: "admin-count-planned", + date: "2026-09-01", + title: "Admin count planned", + distanceKm: 5, + status: "planned", + }).expect(201); + await target.agent.post("/api/races").set("X-CSRF-Token", targetCsrfToken).send({ + slug: "admin-count-completed", + date: "2026-09-02", + title: "Admin count completed", + distanceKm: 10, + status: "completed", + }).expect(201); + + const listed = await admin.agent.get("/api/admin/users").expect(200); + assert.ok(Array.isArray(listed.body)); + const listedTarget = listed.body.find((user: { id: string }) => user.id === targetId); + assert.ok(listedTarget); + assert.deepEqual(Object.keys(listedTarget).sort(), [ + "completedRacesCount", + "createdAt", + "email", + "emailVerifiedAt", + "id", + "isCompletedRacesPublic", + "isFutureRacesPublic", + "lastLoginAt", + "profileUsername", + "racesCount", + ]); + assert.equal(listedTarget.email, "admin-target@example.com"); + assert.equal(typeof listedTarget.createdAt, "string"); + assert.equal(typeof listedTarget.lastLoginAt, "string"); + assert.equal(listedTarget.racesCount, 2); + assert.equal(listedTarget.completedRacesCount, 1); + + await admin.agent + .patch(`/api/admin/users/${targetId}`) + .send({ profileUsername: "missing-csrf" }) + .expect(403) + .then((res) => assert.equal(res.body.error, "csrf_error")); + await admin.agent + .patch(`/api/admin/users/${targetId}`) + .set("X-CSRF-Token", adminCsrfToken) + .send({ unexpected: true }) + .expect(400); + await admin.agent + .patch("/api/admin/users/00000000-0000-4000-8000-000000000000") + .set("X-CSRF-Token", adminCsrfToken) + .send({ profileUsername: "missing-user" }) + .expect(404); + await admin.agent + .patch(`/api/admin/users/${unverifiedId}`) + .set("X-CSRF-Token", adminCsrfToken) + .send({ email: "admin-unverified@example.com", profileUsername: "unverified-edit" }) + .expect(204); + const unchangedEmailTokens = await pool.query<{ count: string }>( + "SELECT COUNT(*)::text AS count FROM email_verification_tokens WHERE user_id = $1 AND used_at IS NULL", + [unverifiedId], + ); + assert.equal(Number(unchangedEmailTokens.rows[0]?.count ?? "0"), 0); + await admin.agent + .patch(`/api/admin/users/${targetId}`) + .set("X-CSRF-Token", adminCsrfToken) + .send({ + email: "admin-target-edited@example.com", + profileUsername: "admin-edited", + isFutureRacesPublic: true, + isCompletedRacesPublic: true, + }) + .expect(204); + const edited = await pool.query<{ + email: string; + profile_username: string; + is_future_races_public: boolean; + is_completed_races_public: boolean; + }>( + `SELECT email, profile_username, is_future_races_public, is_completed_races_public + FROM users WHERE id = $1`, + [targetId], + ); + assert.deepEqual(edited.rows[0], { + email: "admin-target-edited@example.com", + profile_username: "admin-edited", + is_future_races_public: true, + is_completed_races_public: true, + }); + + await admin.agent + .patch(`/api/admin/users/${duplicateId}`) + .set("X-CSRF-Token", adminCsrfToken) + .send({ email: "admin-target-edited@example.com" }) + .expect(409); + await admin.agent + .patch(`/api/admin/users/${adminId}`) + .set("X-CSRF-Token", adminCsrfToken) + .send({ email: "former-admin@example.com" }) + .expect(409); + await admin.agent + .delete(`/api/admin/users/${adminId}`) + .set("X-CSRF-Token", adminCsrfToken) + .expect(409); + + const reset = await admin.agent + .post(`/api/admin/users/${targetId}/password-reset`) + .set("X-CSRF-Token", adminCsrfToken) + .expect(202); + assert.deepEqual(reset.body, { ok: true }); + assert.equal("token" in reset.body, false); + const resetTokens = await pool.query<{ count: string }>( + "SELECT COUNT(*)::text AS count FROM password_reset_tokens WHERE user_id = $1 AND used_at IS NULL", + [targetId], + ); + assert.equal(Number(resetTokens.rows[0]?.count ?? "0"), 1); + + await admin.agent + .delete(`/api/admin/users/${targetId}`) + .set("X-CSRF-Token", adminCsrfToken) + .expect(204); + const deleted = await pool.query<{ users: string; races: string }>( + `SELECT + (SELECT COUNT(*)::text FROM users WHERE id = $1) AS users, + (SELECT COUNT(*)::text FROM races WHERE owner_user_id = $1) AS races`, + [targetId], + ); + assert.deepEqual(deleted.rows[0], { users: "0", races: "0" }); +}); + test("account session endpoints list and revoke only the current user's sessions", async () => { userCounter += 1; const email = `sessions${userCounter}@example.com`; diff --git a/backend/test/setup.ts b/backend/test/setup.mts similarity index 100% rename from backend/test/setup.ts rename to backend/test/setup.mts diff --git a/docs/admin-dashboard-plan.md b/docs/admin-dashboard-plan.md new file mode 100644 index 0000000..cde9ec7 --- /dev/null +++ b/docs/admin-dashboard-plan.md @@ -0,0 +1,175 @@ +# План отдельной админки + +## Цель и границы + +Добавить в существующее приложение отдельный раздел `/admin`, доступный только +пользователю с нормализованным email `av_sports@mail.ru`. Раздел показывает всех +пользователей и позволяет управлять их учётными записями. + +Отдельный frontend, отдельный деплой, универсальная ролевая модель, массовые +операции, фильтры и пагинация в первую версию не входят: для одного известного +администратора они не нужны. Авторизация всегда проверяется backend; скрытие +ссылки или маршрута во frontend не считается защитой. + +## Трактовки требований + +- «Первый пользователь» означает только учётную запись с + `LOWER(BTRIM(email)) = 'av_sports@mail.ru'`, а не первую строку по времени или + UUID. Её email и саму запись запрещено изменять или удалять через админку, + чтобы доступ нельзя было случайно передать или потерять. +- Дата регистрации — `users.created_at`. +- Дата последнего входа — отдельное nullable-поле `users.last_login_at`, которое + обновляется только после успешной проверки пароля. `sessions.last_seen_at` не + используется: это дата активности сессии, а не входа. +- Количество введённых забегов — число всех `races` владельца; количество + совершённых пробегов — число его стартов со статусом `completed`. +- Редактируемые поля первой версии: email, публичное имя и два существующих + флага видимости будущих и завершённых стартов. Системные даты, счётчики, + хеш пароля и право администратора не редактируются. +- При смене email он нормализуется и проверяется на уникальность, подтверждение + сбрасывается, активные сессии отзываются, на новый адрес отправляется обычное + письмо подтверждения. Это не позволяет сохранить подтверждённую сессию после + назначения другого почтового адреса. +- «Сбросить пароль» означает создать одноразовый токен штатным механизмом и + отправить владельцу ссылку. Администратор не видит токен и не назначает пароль; + повторный запрос делает прежнюю неиспользованную ссылку недействительной. +- Удаление пользователя физическое. Существующие внешние ключи с + `ON DELETE CASCADE` удалят его сессии, токены и забеги. + +## API и данные + +- Миграция добавляет `users.last_login_at TIMESTAMPTZ`; успешный login обновляет + поле в той же операции, которая создаёт сессию. +- Общий middleware `requireAdmin` сначала требует подтверждённую сессию, затем + сравнивает нормализованный email с фиксированным адресом администратора. +- `GET /api/admin/users` возвращает пользователей в стабильном порядке с датами, + полями профиля и двумя агрегированными счётчиками без N+1 запросов. +- `PATCH /api/admin/users/:id` строго принимает только заявленные поля. +- `POST /api/admin/users/:id/password-reset` переиспользует создание токена и + отправку письма из текущего auth flow. +- `DELETE /api/admin/users/:id` удаляет неадминистративную учётную запись в + транзакции. Все изменяющие запросы продолжают проходить существующую + CSRF-защиту. +- Ответы различают `401` (нет сессии), `403` (не администратор), `404` + (пользователь отсутствует), `409` (занятый email/username или защищённая + административная запись) и `400` (невалидные данные). + +## Спринты и параллельная работа + +### Спринт 1 — данные, защита и API + +Поток backend: + +- миграция `last_login_at`, обновление успешного входа; +- `requireAdmin` и административные endpoints списка, редактирования, сброса + пароля и удаления; +- тесты доступа, дат, счётчиков, валидации и каждой изменяющей операции. + +Поток delivery/docs параллельно: + +- этот план и включение существующего CI для PR в интеграционную ветку; +- сверка API-контракта, миграции и тестовых сценариев с реализацией backend. + +Критерии приёмки: + +- неавторизованный запрос получает `401`, любой подтверждённый пользователь, + кроме фиксированного администратора, — `403` на каждом admin endpoint; +- список содержит корректные `createdAt`, nullable `lastLoginAt` и оба счётчика; +- сброс отправляет штатную ссылку, edit соблюдает границы полей и уникальность; +- администратора нельзя удалить или лишить фиксированного email; +- backend build, миграции и тесты, frontend build проходят в CI. + +### Спринт 2 — интерфейс администратора + +Поток frontend: + +- типизированный API-клиент, защищённый маршрут `/admin`, ссылка только для + администратора и таблица всех пользователей; +- формы редактирования, подтверждения сброса пароля и удаления, состояния + загрузки/ошибки/пустого списка и обновление данных после операции. + +Поток проверки параллельно: + +- проверка клавиатурной навигации, фокуса, подписей полей и подтверждений; +- ручные сценарии для администратора, обычного пользователя и истёкшей сессии; +- сверка клиентских типов с фактическими ответами API. + +Критерии приёмки: + +- обычный пользователь не видит вход в админку, а прямой переход не раскрывает + данные и завершается отказом backend; +- таблица показывает email, регистрацию, последний вход, оба счётчика и действия; +- действия имеют понятное подтверждение, блокируются на время запроса и + показывают результат без перезагрузки страницы; +- интерфейс пригоден для клавиатуры и узкого экрана; frontend и backend checks + проходят. + +### Спринт 3 — сквозная проверка и выпуск + +Поток качества: + +- регрессия регистрации, входа, CSRF, сброса пароля и CRUD забегов; +- сквозная проверка admin list/edit/reset/delete на отдельной тестовой записи; +- проверка миграции на копии схемы и поведения удаления с каскадами. + +Поток выпуска параллельно: + +- актуализация API-документации и операционной памятки; +- minor bump затронутых backend/frontend после стабилизации всего функционала; +- после зелёных проверок и устранения замечаний — запись пользовательски значимых + изменений в `CHANGELOG.md` и финальное независимое ревью. + +Критерии приёмки: + +- все требования этого документа подтверждены тестом или зафиксированным ручным + сценарием; +- backend tests/build и frontend production build проходят, блокирующих замечаний + независимого ревью нет; +- версии видны в футере, changelog соответствует фактическому составу выпуска; +- результат оставлен в рабочем дереве; PR в локальном режиме не создаётся. + +## Ветки, PR и проверки + +Первоначально поставка планировалась через `feature/admin-dashboard-integration`, +PR каждого спринта в неё и финальный PR в `main`. По последующему указанию +владельца эта работа выполняется локально: без commit, push и PR. Независимое +ревью каждого блока и все проверки сохраняются; итоговые изменения остаются в +рабочем дереве для последующей поставки владельцем. + +Если владелец возобновит Git-доставку, применяется первоначально согласованная +схема: + +1. От актуального `origin/main` создаётся + `feature/admin-dashboard-integration`; напрямую функциональный код в неё не + вносится. +2. Последовательно создаются `feature/admin-dashboard-s1-backend`, + `feature/admin-dashboard-s2-frontend` и + `feature/admin-dashboard-s3-hardening`, каждая от актуальной интеграционной + ветки. Параллельные агенты работают внутри границ текущего спринта, результат + собирается в одну sprint-ветку и один PR. +3. Каждый sprint PR направляется в `feature/admin-dashboard-integration`. После + каждого push отдельный reviewer проверяет полный diff и оставляет PR-комментарий. + Замечания исправляются в том же PR, checks и независимое ревью повторяются до + отсутствия блокирующих замечаний. +4. Слияние sprint PR выполняет сопровождающий вручную только после зелёных checks + и ревью. Автоматическое слияние запрещено. Следующий спринт начинается от + обновлённой интеграционной ветки. +5. После трёх спринтов открывается единственный финальный PR + `feature/admin-dashboard-integration` → `main`. Он снова проходит полный CI и + независимое ревью и остаётся готовым к ручному слиянию. Этот шаг сейчас + отложен по явному указанию работать локально. + +Для каждого блока обязательны backend build и tests на PostgreSQL 16, а также +frontend production build. В локальном режиме эти проверки запускаются вручную. + +## Риски удаления + +- Удаление необратимо и каскадно уничтожает историю забегов, сессии и auth-токены. + UI должен требовать явного подтверждения с email удаляемого пользователя и + повторно показывать последствия. +- Перед первым production-использованием удаления нужна проверенная резервная + копия PostgreSQL и известная процедура восстановления. +- Backend запрещает self-delete администратора независимо от состояния UI и + проверяет целевую запись непосредственно перед `DELETE` в транзакции. +- Ответ об успехе показывается только после commit; при ошибке ни одна часть + учётной записи не считается удалённой. diff --git a/docs/backend-api-for-frontend.md b/docs/backend-api-for-frontend.md index 3add33c..058de57 100644 --- a/docs/backend-api-for-frontend.md +++ b/docs/backend-api-for-frontend.md @@ -57,6 +57,46 @@ SPA обращается к относительному префиксу `/api` Отзывает все активные сессии, кроме текущей. Успех: `200 { "ok": true }`. +## Администрирование пользователей + +Раздел доступен только подтверждённому аккаунту `av_sports@mail.ru` после +нормализации адреса. Все изменяющие запросы требуют `X-CSRF-Token`. + +### `GET /api/admin/users` + +Возвращает массив пользователей с датой регистрации, последнего входа и +количеством всех и завершённых забегов. `lastLoginAt` может быть `null`, если +входов ещё не было. Для существующих аккаунтов миграция использует наиболее +позднюю сохранённую дату создания сессии; старые уже очищенные сессии не +восстановимы. + +### `PATCH /api/admin/users/:id` + +Принимает непустой объект из `email`, `profileUsername`, `isFutureRacesPublic`, +`isCompletedRacesPublic`. Неизвестные поля отклоняются. При смене email старые +сессии завершаются, email становится неподтверждённым, а на новый адрес +отправляется ссылка подтверждения. Адрес администратора нельзя менять или +назначать другому пользователю. Письмо отправляется до фиксации изменений в БД; +если SMTP вернёт ошибку, API ответит `500 { "error": "unknown_error" }`, а +изменения email, профиля, сессий и токенов будут отменены. Успех: `204`. + +### `POST /api/admin/users/:id/password-reset` + +Создаёт одноразовую ссылку штатным потоком восстановления пароля и отправляет её +владельцу по email. Сам токен в ответе не возвращается. Успех: `202` +`{ "ok": true }`; действие ограничено rate limit. Токен фиксируется в БД только +после успешной отправки письма; при ошибке SMTP API вернёт +`500 { "error": "unknown_error" }`, а новая ссылка и отзыв предыдущей будут +отменены. + +### `DELETE /api/admin/users/:id` + +Удаляет аккаунт и каскадно связанные с ним забеги, сессии и токены. Удалить +администратора нельзя. Успех: `204`. + +В production требуется настроенный `SMTP_HOST`; backend не запустится без него, +чтобы операции со ссылками не сообщали об успехе без отправки почты. + ## Служебные маршруты ### `GET /api/health` diff --git a/docs/backend.md b/docs/backend.md index 21597db..3fb02e5 100644 --- a/docs/backend.md +++ b/docs/backend.md @@ -76,10 +76,22 @@ API слушает порт: **`PORT`** (если задан), иначе **`API | `PORT` | Порт API (приоритетнее `API_PORT`) | — | | `API_PORT` | Порт API-сервера | `3001` | | `CORS_ORIGIN` | Разрешённый origin для CORS | `http://localhost:5173` | +| `APP_BASE_URL` | Базовый URL frontend для ссылок подтверждения email и сброса пароля | production: `https://run.vakanaut.ru`, иначе `http://localhost:5173` | +| `SMTP_HOST` | SMTP-сервер; обязателен в production | — | +| `SMTP_PORT` | Порт SMTP | `587` | +| `SMTP_SECURE` | Использовать TLS-соединение с момента подключения | `false` | +| `SMTP_USER`, `SMTP_PASSWORD` | Учётные данные SMTP (если требуются сервером) | — | +| `SMTP_FROM` | Имя и адрес отправителя | `Calendar Run ` | Фронтенд всегда обращается к API по префиксу `/api` на текущем origin. В локальной разработке этот префикс проксирует Vite (`frontend/vite.config.ts`) на `http://localhost:3001`; в Docker-стеке — nginx фронта проксирует на `runners-calendar-backend:3000`. +`APP_BASE_URL` задаёт origin страниц, на которые ведут ссылки в письмах. Для +production без этой переменной используется `https://run.vakanaut.ru`; задайте +своё значение, если frontend размещён на другом домене. В локальной разработке +используется `http://localhost:5173`. + При отсутствии любой из `DB_*` процесс падает при старте: `Missing required environment variable: `. +В production backend также не запускается без `SMTP_HOST`. Для тестов нужен отдельный PostgreSQL database `calendar_run_test`; `npm test` применяет миграции и очищает его перед запуском. @@ -96,7 +108,13 @@ API слушает порт: **`PORT`** (если задан), иначе **`API backend/ ├── migrations/ │ ├── 001_create_races.sql -│ └── 002_finish_place_and_registered_status.sql +│ ├── 002_finish_place_and_registered_status.sql +│ ├── 003_cover_image_url.sql +│ ├── 004_auth_and_race_ownership.sql +│ ├── 005_public_profiles.sql +│ ├── 006_profile_usernames_and_visibility.sql +│ ├── 007_cover_image_source.sql +│ └── 008_user_last_login.sql ├── src/ │ ├── app.ts # фабрика Express (тесты) │ ├── config.ts # загрузка и валидация env @@ -107,8 +125,11 @@ backend/ │ ├── mappers/ │ │ └── race.ts # snake_case ↔ camelCase │ └── routes/ +│ ├── admin.ts # /api/admin/users +│ ├── auth.ts # регистрация, вход, сессии и восстановление │ ├── health.ts # /api/health, /api/ready, /api/meta -│ └── races.ts # CRUD /api/races +│ ├── races.ts # CRUD /api/races +│ └── users.ts # публичные профили ├── package.json └── tsconfig.json ``` diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 50e635c..8187791 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-frontend", - "version": "0.10.2", + "version": "0.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-frontend", - "version": "0.10.2", + "version": "0.11.0", "dependencies": { "ag-charts-community": "^14.0.0", "ag-charts-react": "^14.0.0", diff --git a/frontend/package.json b/frontend/package.json index bd0c3e2..92f9a54 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "calendar-run-frontend", "private": true, - "version": "0.10.2", + "version": "0.11.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts new file mode 100644 index 0000000..e0ab4cb --- /dev/null +++ b/frontend/src/api/admin.ts @@ -0,0 +1,54 @@ +import { ApiError } from "./errors"; +import { requestJson } from "./http"; +import type { AdminUser, AdminUserUpdate } from "./types"; + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + +function isDateString(value: unknown): value is string { + return typeof value === "string" && Number.isFinite(Date.parse(value)); +} + +function isAdminUser(value: unknown): value is AdminUser { + if (!value || typeof value !== "object") return false; + const user = value as Partial; + return ( + typeof user.id === "string" && + typeof user.email === "string" && + isNullableString(user.emailVerifiedAt) && + isNullableString(user.profileUsername) && + typeof user.isFutureRacesPublic === "boolean" && + typeof user.isCompletedRacesPublic === "boolean" && + isDateString(user.createdAt) && + (user.lastLoginAt === null || isDateString(user.lastLoginAt)) && + typeof user.racesCount === "number" && Number.isInteger(user.racesCount) && user.racesCount >= 0 && + typeof user.completedRacesCount === "number" && + Number.isInteger(user.completedRacesCount) && + user.completedRacesCount >= 0 && + user.completedRacesCount <= user.racesCount + ); +} + +export async function getAdminUsers(): Promise { + const response = await requestJson("/admin/users"); + if (!Array.isArray(response) || !response.every(isAdminUser)) { + throw new ApiError({ code: "unknown_error", message: "Некорректный формат данных от API." }); + } + return response; +} + +export async function updateAdminUser(id: string, payload: AdminUserUpdate): Promise { + await requestJson(`/admin/users/${encodeURIComponent(id)}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function requestAdminPasswordReset(id: string): Promise { + await requestJson(`/admin/users/${encodeURIComponent(id)}/password-reset`, { method: "POST" }); +} + +export async function deleteAdminUser(id: string): Promise { + await requestJson(`/admin/users/${encodeURIComponent(id)}`, { method: "DELETE" }); +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 36a9912..1a5fa90 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,9 +1,21 @@ -export type { AuthSession, AuthUser, CreateRacePayload, PublicRace, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types"; +export type { + AdminUser, + AdminUserUpdate, + AuthSession, + AuthUser, + CreateRacePayload, + PublicRace, + 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 { getPublicRaces } from "./users"; +export { deleteAdminUser, getAdminUsers, requestAdminPasswordReset, updateAdminUser } from "./admin"; export { forgotPassword, changePassword, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index c7007c5..4281930 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -56,6 +56,27 @@ export interface AuthUser { profileUsername: string | null; isFutureRacesPublic: boolean; isCompletedRacesPublic: boolean; + isAdmin: boolean; +} + +export interface AdminUser { + id: string; + email: string; + emailVerifiedAt: string | null; + profileUsername: string | null; + isFutureRacesPublic: boolean; + isCompletedRacesPublic: boolean; + createdAt: string; + lastLoginAt: string | null; + racesCount: number; + completedRacesCount: number; +} + +export interface AdminUserUpdate { + email?: string; + profileUsername?: string | null; + isFutureRacesPublic?: boolean; + isCompletedRacesPublic?: boolean; } export interface PublicRace { diff --git a/frontend/src/app/auth/RequireAdmin.tsx b/frontend/src/app/auth/RequireAdmin.tsx new file mode 100644 index 0000000..c6dfd61 --- /dev/null +++ b/frontend/src/app/auth/RequireAdmin.tsx @@ -0,0 +1,7 @@ +import { Navigate, Outlet } from "react-router-dom"; +import { useAuth } from "./AuthContext"; + +export function RequireAdmin(): JSX.Element { + const { user } = useAuth(); + return user?.isAdmin ? : ; +} diff --git a/frontend/src/app/layouts/AppLayout.tsx b/frontend/src/app/layouts/AppLayout.tsx index e3ed9d8..316f93e 100644 --- a/frontend/src/app/layouts/AppLayout.tsx +++ b/frontend/src/app/layouts/AppLayout.tsx @@ -39,6 +39,16 @@ export function AppLayout(): JSX.Element { {user ? ( <> + {user.isAdmin ? ( + + isActive ? "app-shell__link app-shell__link--active" : "app-shell__link" + } + > + Админка + + ) : null} diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index da41770..f404c91 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -9,6 +9,8 @@ import { AccountPage } from "../pages/AccountPage"; import { PublicProfilePage } from "../pages/PublicProfilePage"; import { ForgotPasswordPage, LoginPage, RegisterPage, ResetPasswordPage, VerifyEmailPage } from "../pages/AuthPages"; import { RequireAuth } from "./auth/RequireAuth"; +import { RequireAdmin } from "./auth/RequireAdmin"; +import { AdminPage } from "../pages/AdminPage"; export const appRouter = createBrowserRouter([ { @@ -31,6 +33,10 @@ export const appRouter = createBrowserRouter([ { path: "races/day/:ymd", element: }, { path: "races/:raceId", element: }, { path: "races/:raceId/edit", element: }, + { + element: , + children: [{ path: "admin", element: }], + }, ], }, ], diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx new file mode 100644 index 0000000..aa6d1fe --- /dev/null +++ b/frontend/src/pages/AdminPage.tsx @@ -0,0 +1,268 @@ +import { Fragment, useCallback, useEffect, useRef, useState } from "react"; +import type { AdminUser } from "../api"; +import { + ApiError, + deleteAdminUser, + getAdminUsers, + requestAdminPasswordReset, + updateAdminUser, +} from "../api"; + +const ADMIN_EMAIL = "av_sports@mail.ru"; + +type Panel = { userId: string; kind: "edit" | "reset" | "delete" } | null; + +interface EditDraft { + email: string; + profileUsername: string; + isFutureRacesPublic: boolean; + isCompletedRacesPublic: boolean; +} + +function formatDate(value: string): string { + return new Intl.DateTimeFormat("ru-RU", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); +} + +function errorMessage(error: unknown, fallback: string): string { + return error instanceof ApiError ? error.message : fallback; +} + +function editDraft(user: AdminUser): EditDraft { + return { + email: user.email, + profileUsername: user.profileUsername ?? "", + isFutureRacesPublic: user.isFutureRacesPublic, + isCompletedRacesPublic: user.isCompletedRacesPublic, + }; +} + +export function AdminPage(): JSX.Element { + const [users, setUsers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [panel, setPanel] = useState(null); + const [draft, setDraft] = useState(null); + const [deleteConfirmation, setDeleteConfirmation] = useState(""); + const [busyUserId, setBusyUserId] = useState(null); + const [statusMessage, setStatusMessage] = useState(""); + const [errorMessageText, setErrorMessageText] = useState(""); + const triggerRef = useRef(null); + const tableRef = useRef(null); + + const loadUsers = useCallback(async (showLoading = false) => { + if (showLoading) setIsLoading(true); + try { + setUsers(await getAdminUsers()); + } catch (error) { + setErrorMessageText(errorMessage(error, "Не удалось загрузить пользователей. Обновите страницу и попробуйте снова.")); + } finally { + if (showLoading) setIsLoading(false); + } + }, []); + + useEffect(() => { + void loadUsers(true); + }, [loadUsers]); + + function clearMessages(): void { + setStatusMessage(""); + setErrorMessageText(""); + } + + function closePanel(): void { + setPanel(null); + setDraft(null); + setDeleteConfirmation(""); + window.requestAnimationFrame(() => { + if (triggerRef.current?.isConnected) triggerRef.current.focus(); + else tableRef.current?.focus(); + }); + } + + function openEdit(user: AdminUser): void { + clearMessages(); + setPanel({ userId: user.id, kind: "edit" }); + setDraft(editDraft(user)); + setDeleteConfirmation(""); + } + + function openConfirmation(userId: string, kind: "reset" | "delete"): void { + clearMessages(); + setPanel({ userId, kind }); + setDraft(null); + setDeleteConfirmation(""); + } + + async function saveUser(event: React.FormEvent, user: AdminUser): Promise { + event.preventDefault(); + if (!draft) return; + const emailChanged = draft.email.trim().toLowerCase() !== user.email.trim().toLowerCase(); + clearMessages(); + setBusyUserId(user.id); + try { + await updateAdminUser(user.id, { + ...(emailChanged ? { email: draft.email.trim() } : {}), + profileUsername: draft.profileUsername.trim() || null, + isFutureRacesPublic: draft.isFutureRacesPublic, + isCompletedRacesPublic: draft.isCompletedRacesPublic, + }); + closePanel(); + setStatusMessage(emailChanged + ? `Адрес обновлён. На ${draft.email.trim()} отправлена ссылка для подтверждения; активные сессии пользователя завершены.` + : `Данные пользователя ${user.email} сохранены.`); + await loadUsers(); + } catch (error) { + setErrorMessageText(error instanceof ApiError && error.code === "conflict" + ? "Этот email или публичное имя уже используются. Укажите другие данные." + : error instanceof ApiError && error.status === 409 + ? "Этот email закреплён за администратором и не может быть назначен другому аккаунту." + : errorMessage(error, "Не удалось сохранить данные. Проверьте соединение и попробуйте снова.")); + } finally { + setBusyUserId(null); + } + } + + async function sendPasswordReset(user: AdminUser): Promise { + clearMessages(); + setBusyUserId(user.id); + try { + await requestAdminPasswordReset(user.id); + closePanel(); + setStatusMessage(`Ссылка для сброса пароля отправлена на ${user.email}.`); + } catch (error) { + setErrorMessageText(errorMessage(error, "Не удалось отправить ссылку. Попробуйте снова.")); + } finally { + setBusyUserId(null); + } + } + + async function deleteUser(user: AdminUser): Promise { + if (deleteConfirmation !== user.email) return; + clearMessages(); + setBusyUserId(user.id); + try { + await deleteAdminUser(user.id); + closePanel(); + setStatusMessage(`Аккаунт ${user.email} и его забеги удалены.`); + await loadUsers(); + tableRef.current?.focus(); + } catch (error) { + setErrorMessageText(errorMessage(error, "Не удалось удалить аккаунт. Попробуйте снова.")); + } finally { + setBusyUserId(null); + } + } + + return ( +
+

Пользователи

+

Просмотр и управление учётными записями Calendar Run.

+ +

+ {statusMessage} +

+ {!panel ?

+ {errorMessageText} +

: null} + + {isLoading ?

Загружаем пользователей…

: null} + {!isLoading && users.length === 0 && !errorMessageText ? ( +

Пользователей пока нет.

+ ) : null} + + {!isLoading && users.length > 0 ? ( +
+ + + + + + + + + + + + + + {users.map((user) => { + const isAdmin = user.email.trim().toLowerCase() === ADMIN_EMAIL; + const isBusy = busyUserId === user.id; + const activePanel = panel?.userId === user.id ? panel.kind : null; + + return ( + + + + + + + + + + + + ); + })} + +
Все зарегистрированные пользователи
ПользовательРегистрацияПоследний входВсего забеговЗавершённыеДействия
+ {user.email} + + {user.profileUsername ? `@${user.profileUsername}` : "Без публичного имени"} + + + {user.lastLoginAt ? ( + + ) : "Ещё не входил"} + {user.racesCount}{user.completedRacesCount} +
+ + + {!isAdmin ? ( + + ) : null} +
+
+
+ ) : null} + + {panel ? (() => { + const user = users.find((item) => item.id === panel.userId); + if (!user) return null; + const isBusy = busyUserId === user.id; + const isAdmin = user.email.trim().toLowerCase() === ADMIN_EMAIL; + return ( +
+

+ {errorMessageText} +

+ {panel.kind === "edit" && draft ? ( +
void saveUser(event, user)}> +

Редактировать {user.email}

+ {isAdmin ?

Email администратора закреплён для сохранения доступа.

: null} + + {!isAdmin ?

При смене адреса пользователь выйдет из активных сессий и должен будет подтвердить новый email.

: null} + +
Публичная страница
+
+
+ ) : null} + {panel.kind === "reset" ?

Отправить ссылку для сброса пароля?

Новая ссылка будет отправлена на {user.email}. Предыдущая неиспользованная ссылка перестанет работать.

: null} + {panel.kind === "delete" ?

Удалить аккаунт?

Аккаунт {user.email} и все его забеги будут удалены без возможности восстановления.

: null} +
+ ); + })() : null} +
+ ); +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 8248425..b959ef8 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -2226,3 +2226,160 @@ body { transform: none; } } + +/* ─── Admin users ──────────────────────────────────────── */ + +.admin-users__message { + margin: 0; +} + +.admin-users__message:not(:empty), +.admin-users__loading, +.admin-users__empty { + margin: var(--space-4) 0 0; +} + +.admin-users__message--status { + color: var(--color-success); +} + +.admin-users__message--error { + color: var(--color-error); +} + +.admin-users__table-scroll { + margin-top: var(--space-5); + overflow-x: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface); +} + +.admin-users__table-scroll:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.admin-users__table { + width: 100%; + min-width: 64rem; + border-collapse: collapse; +} + +.admin-users__table caption { + padding: var(--space-3) var(--space-4); + text-align: start; + font-weight: 700; +} + +.admin-users__table th, +.admin-users__table td { + padding: var(--space-3) var(--space-4); + border-top: 1px solid var(--color-border); + text-align: start; + vertical-align: top; +} + +.admin-users__table thead th { + color: var(--color-text-muted); + font-size: var(--font-size-caption); +} + +.admin-users__email, +.admin-users__username { + display: block; +} + +.admin-users__username { + margin-top: var(--space-1); + color: var(--color-text-muted); + font-size: var(--font-size-caption); + font-weight: 400; +} + +.admin-users__actions, +.admin-users__panel-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); +} + +.admin-users__panel { + max-width: 52rem; + margin-top: var(--space-5); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-surface-soft); +} + +.admin-users__form, +.admin-users__confirm { + display: grid; + gap: var(--space-4); +} + +.admin-users__form { + grid-template-columns: repeat(2, minmax(14rem, 1fr)); +} + +.admin-users__panel-title, +.admin-users__confirm p { + grid-column: 1 / -1; + margin: 0; +} + +.admin-users__panel-title { + font-size: var(--font-size-body); +} + +.admin-users__field { + display: grid; + gap: var(--space-1); + color: var(--color-text-muted); + font-size: var(--font-size-caption); + font-weight: 600; +} + +.admin-users__visibility { + display: grid; + grid-column: 1 / -1; + gap: var(--space-2); + margin: 0; + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); +} + +.admin-users__visibility label { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.admin-users__panel-actions { + grid-column: 1 / -1; +} + +.admin-users__confirm { + max-width: 44rem; +} + +.admin-users__confirm--danger { + padding: var(--space-4); + border: 1px solid var(--color-error); + border-radius: var(--radius-sm); + background: #fef2f2; +} + +.admin-users__note { + grid-column: 1 / -1; + margin: 0; + color: var(--color-text-muted); +} + +@media (max-width: 560px) { + .admin-users__form { + grid-template-columns: 1fr; + } +}