feat(admin): add user management dashboard #51

Merged
admin merged 2 commits from feature/admin-dashboard-s1-backend into main 2026-09-25 17:22:40 +00:00
29 changed files with 1346 additions and 41 deletions

No files matched your search

+3 -2
View File
@@ -28,8 +28,9 @@ API_PORT=3001
CORS_ORIGIN=http://localhost:5173
# ─── Auth / sessions ─────────────────────────────────────────
# APP_BASE_URL is the only source for verify/reset email links.
APP_BASE_URL=http://localhost:5173
# Базовый URL ссылок подтверждения и сброса пароля. В production по умолчанию
# используется https://run.vakanaut.ru; задайте URL явно для другого домена.
# APP_BASE_URL=http://localhost:5173
Outdated
Review

P2 — заявленный production fallback https://run.vakanaut.ru не сработает при документированном развёртывании. README и docker-compose.stack.yml предлагают скопировать .env.example в .env, а эта строка задаёт APP_BASE_URL=http://localhost:5173; config.ts берёт заданное значение раньше production fallback. В результате ссылки подтверждения email и сброса пароля в письмах будут вести на localhost, если оператор следует инструкции и меняет только перечисленные DB_* / CORS_ORIGIN. Нужно убрать значение из шаблона (оставить закомментированным) или явно требовать сменить APP_BASE_URL в шагах production-развёртывания.

P2 — заявленный production fallback `https://run.vakanaut.ru` не сработает при документированном развёртывании. README и docker-compose.stack.yml предлагают скопировать `.env.example` в `.env`, а эта строка задаёт `APP_BASE_URL=http://localhost:5173`; `config.ts` берёт заданное значение раньше production fallback. В результате ссылки подтверждения email и сброса пароля в письмах будут вести на localhost, если оператор следует инструкции и меняет только перечисленные DB_* / CORS_ORIGIN. Нужно убрать значение из шаблона (оставить закомментированным) или явно требовать сменить APP_BASE_URL в шагах production-развёртывания.
SESSION_SECRET=replace_with_32plus_char_random_secret
# Production defaults to __Host-sid + Secure cookies. Local dev can stay insecure over http.
# SESSION_COOKIE_NAME=__Host-sid
+47 -1
View File
@@ -3,10 +3,38 @@
Все заметки о выпусках 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.2] — подготовлено локально, 2026-09-25
#### Fixed
- Запросы сброса пароля и повторной отправки подтверждения сериализованы со сменой email.
- Письма для локальной разработки снова содержат ссылку в dev-логах; в production токены не логируются.
- Шаблон окружения оставляет `APP_BASE_URL` незаданным, чтобы production использовал домен по умолчанию.
### [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 +136,24 @@
## Frontend
### [0.11.2] — подготовлено локально, 2026-09-25
#### Fixed
- Таблица пользователей прокручивается внутри контейнера на узких экранах, не расширяя страницу.
### [0.11.1] — подготовлено локально, 2026-09-24
#### Fixed
- PATCH админки не отправляет email без изменений; ошибки API показываются рядом с открытой панелью.
### [0.11.0] — подготовлено локально, 2026-09-24
#### Added
- Страница администрирования пользователей со статистикой забегов и действиями управления аккаунтом.
### [0.10.2] — 2026-08-14
#### Fixed
@@ -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;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "calendar-run-backend",
"version": "1.6.0",
"version": "1.7.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-backend",
"version": "1.6.0",
"version": "1.7.2",
"dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "calendar-run-backend",
"version": "1.6.0",
"version": "1.7.2",
"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",
+2
View File
@@ -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) => {
+11
View File
@@ -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();
+52 -21
View File
@@ -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<SessionRow>(
`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<SessionRow>(
`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,
};
@@ -439,14 +456,21 @@ export async function verifyEmailToken(token: string): Promise<boolean> {
export async function requestPasswordReset(email: string): Promise<void> {
const normalized = normalizeEmail(email);
const user = await findUserByEmail(normalized);
if (!user) {
securityLog("password_reset.requested_missing", { emailHash: anonymizeEmail(normalized) });
return;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query<UserRow>(
`SELECT id, email, password_hash, email_verified_at, profile_username,
is_future_races_public, is_completed_races_public
FROM users WHERE LOWER(BTRIM(email)) = $1 FOR UPDATE`,
[normalized],
);
const user = rows[0];
if (!user) {
await client.query("COMMIT");
securityLog("password_reset.requested_missing", { emailHash: anonymizeEmail(normalized) });
return;
}
const token = await createResetToken(client, user.id);
await client.query("COMMIT");
await sendResetEmail(user.email, token);
@@ -504,13 +528,20 @@ export async function resetPassword(token: string, password: string): Promise<bo
export async function resendVerification(email: string): Promise<void> {
const normalized = normalizeEmail(email);
const user = await findUserByEmail(normalized);
if (!user || user.email_verified_at) {
return;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query<UserRow>(
`SELECT id, email, password_hash, email_verified_at, profile_username,
is_future_races_public, is_completed_races_public
FROM users WHERE LOWER(BTRIM(email)) = $1 FOR UPDATE`,
[normalized],
);
const user = rows[0];
if (!user || user.email_verified_at) {
await client.query("COMMIT");
return;
}
const token = await createVerificationToken(client, user.id);
await client.query("COMMIT");
await sendVerificationEmail(user.email, token);
+11 -2
View File
@@ -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"),
+4 -1
View File
@@ -3,7 +3,10 @@ import { config } from "./config";
export async function sendMail(to: string, subject: string, text: string): Promise<void> {
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; development email:", { to, subject, text });
return;
Review

P2 — при SMTP_HOST без значения в development/test sendMail() теперь возвращает успех, не доставив и не сохранив ссылку. Регистрация отвечает 202, но новый пользователь остаётся без подтверждённого email, а loginUser() ему отказывает. Ранее этот путь выводил ссылку в dev-логи, поэтому локальный сценарий регистрации и ручной проверки админки регрессировал. Нужен работающий безопасный dev mail sink или явная ошибка конфигурации до принятия регистрации; токены при этом не должны попадать в production-логи.

P2 — при `SMTP_HOST` без значения в development/test `sendMail()` теперь возвращает успех, не доставив и не сохранив ссылку. Регистрация отвечает 202, но новый пользователь остаётся без подтверждённого email, а `loginUser()` ему отказывает. Ранее этот путь выводил ссылку в dev-логи, поэтому локальный сценарий регистрации и ручной проверки админки регрессировал. Нужен работающий безопасный dev mail sink или явная ошибка конфигурации до принятия регистрации; токены при этом не должны попадать в production-логи.
}
+208
View File
@@ -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]);
Review

P1 — отзыв ссылок при смене email не защищён от параллельного запроса восстановления. requestPasswordReset() сначала читает пользователя по старому адресу через findUserByEmail(), затем в отдельной транзакции вызывает createResetToken(). Если чтение произошло до этого PATCH, а создание токена — после COMMIT, строка 132 не отзовёт новый токен: ссылка будет отправлена на старый адрес и сможет сменить пароль аккаунта с новым email. На PostgreSQL 16.13 воспроизвёл состояние email=race-new@example.com, email_verified_at=NULL, active_reset_tokens=1 после такой последовательности. Аналогичная гонка есть у resendVerification(): старый адрес может получить действующий токен подтверждения нового email. Нужно сериализовать выбор пользователя и выпуск токена с этой сменой email (например, блокировка строки users с повторной проверкой адреса внутри транзакции) и проверить оба сценария тестами.

P1 — отзыв ссылок при смене email не защищён от параллельного запроса восстановления. `requestPasswordReset()` сначала читает пользователя по старому адресу через `findUserByEmail()`, затем в отдельной транзакции вызывает `createResetToken()`. Если чтение произошло до этого PATCH, а создание токена — после COMMIT, строка 132 не отзовёт новый токен: ссылка будет отправлена на старый адрес и сможет сменить пароль аккаунта с новым email. На PostgreSQL 16.13 воспроизвёл состояние `email=race-new@example.com`, `email_verified_at=NULL`, `active_reset_tokens=1` после такой последовательности. Аналогичная гонка есть у `resendVerification()`: старый адрес может получить действующий токен подтверждения нового email. Нужно сериализовать выбор пользователя и выпуск токена с этой сменой email (например, блокировка строки users с повторной проверкой адреса внутри транзакции) и проверить оба сценария тестами.
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;
+8 -2
View File
@@ -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({
+4 -1
View File
@@ -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) => {
+5
View File
@@ -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;
}
+197 -1
View File
@@ -9,10 +9,12 @@ import {
cleanupExpiredAuthRows,
createResetToken,
createVerificationToken,
requestPasswordReset,
resendVerification,
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 +118,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 +283,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`;
@@ -386,6 +554,34 @@ test("email verification token is single use", async () => {
assert.equal(await verifyEmailToken(token), false);
});
test("email changes serialize password reset and verification token issuance", async () => {
for (const [email, issueToken] of [
["reset-race@example.com", requestPasswordReset],
["verify-race@example.com", resendVerification],
] as const) {
const userId = await createUnverifiedUser(email, "correct horse battery staple");
const locker = await pool.connect();
await locker.query("BEGIN");
await locker.query("SELECT id FROM users WHERE id = $1 FOR UPDATE", [userId]);
const issuing = issueToken(email);
await new Promise((resolve) => setTimeout(resolve, 20));
await locker.query("UPDATE users SET email = $2, email_verified_at = NULL WHERE id = $1", [
userId,
email.replace("@", ".changed@"),
]);
await locker.query("COMMIT");
locker.release();
await issuing;
const table = issueToken === requestPasswordReset ? "password_reset_tokens" : "email_verification_tokens";
const { rows } = await pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM ${table} WHERE user_id = $1 AND used_at IS NULL`,
[userId],
);
assert.equal(rows[0]?.count, "0");
}
});
test("auth cleanup removes expired rows and keeps active rows", async () => {
const userId = await createVerifiedUser("cleanup@example.com", "correct horse battery staple");
const past = new Date(Date.now() - 60_000);
File renamed without changes.
+175
View File
@@ -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; при ошибке ни одна часть
учётной записи не считается удалённой.
+40
View File
@@ -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`
+23 -2
View File
@@ -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 <no-reply@example.com>` |
Фронтенд всегда обращается к 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: <NAME>`.
В 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
```
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "calendar-run-frontend",
"version": "0.10.2",
"version": "0.11.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-frontend",
"version": "0.10.2",
"version": "0.11.2",
"dependencies": {
"ag-charts-community": "^14.0.0",
"ag-charts-react": "^14.0.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "calendar-run-frontend",
"private": true,
"version": "0.10.2",
"version": "0.11.2",
"type": "module",
"scripts": {
"dev": "vite",
+54
View File
@@ -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<AdminUser>;
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<AdminUser[]> {
const response = await requestJson<unknown>("/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<void> {
await requestJson<void>(`/admin/users/${encodeURIComponent(id)}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function requestAdminPasswordReset(id: string): Promise<void> {
await requestJson<void>(`/admin/users/${encodeURIComponent(id)}/password-reset`, { method: "POST" });
}
export async function deleteAdminUser(id: string): Promise<void> {
await requestJson<void>(`/admin/users/${encodeURIComponent(id)}`, { method: "DELETE" });
}
+13 -1
View File
@@ -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,
+21
View File
@@ -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 {
+7
View File
@@ -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 ? <Outlet /> : <Navigate to="/" replace />;
}
+10
View File
@@ -39,6 +39,16 @@ export function AppLayout(): JSX.Element {
</NavLink>
{user ? (
<>
{user.isAdmin ? (
<NavLink
to="/admin"
className={({ isActive }) =>
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
}
>
Админка
</NavLink>
) : null}
<NavLink
to="/account"
className={({ isActive }) =>
+6
View File
@@ -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: <RaceDayPage /> },
{ path: "races/:raceId", element: <RaceDetailsPage /> },
{ path: "races/:raceId/edit", element: <RaceFormPage /> },
{
element: <RequireAdmin />,
children: [{ path: "admin", element: <AdminPage /> }],
},
],
},
],
+268
View File
@@ -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<AdminUser[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [panel, setPanel] = useState<Panel>(null);
const [draft, setDraft] = useState<EditDraft | null>(null);
const [deleteConfirmation, setDeleteConfirmation] = useState("");
const [busyUserId, setBusyUserId] = useState<string | null>(null);
const [statusMessage, setStatusMessage] = useState("");
const [errorMessageText, setErrorMessageText] = useState("");
const triggerRef = useRef<HTMLButtonElement | null>(null);
const tableRef = useRef<HTMLDivElement | null>(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<HTMLFormElement>, user: AdminUser): Promise<void> {
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<void> {
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<void> {
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 (
<section className="page admin-users">
<h1 className="page__title">Пользователи</h1>
<p className="page__subtitle">Просмотр и управление учётными записями Calendar Run.</p>
<p className="admin-users__message admin-users__message--status" role="status" aria-live="polite" aria-atomic="true">
{statusMessage}
</p>
{!panel ? <p className="admin-users__message admin-users__message--error" role="alert" aria-live="assertive" aria-atomic="true">
{errorMessageText}
</p> : null}
{isLoading ? <p className="admin-users__loading">Загружаем пользователей…</p> : null}
{!isLoading && users.length === 0 && !errorMessageText ? (
<p className="admin-users__empty">Пользователей пока нет.</p>
) : null}
{!isLoading && users.length > 0 ? (
<div
className="admin-users__table-scroll"
ref={tableRef}
role="region"
aria-label="Таблица пользователей. Прокручивается по горизонтали"
tabIndex={0}
>
<table className="admin-users__table">
<caption>Все зарегистрированные пользователи</caption>
<thead>
<tr>
<th scope="col">Пользователь</th>
<th scope="col">Регистрация</th>
<th scope="col">Последний вход</th>
<th scope="col">Всего забегов</th>
<th scope="col">Завершённые</th>
<th scope="col">Действия</th>
</tr>
</thead>
<tbody>
{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 (
<Fragment key={user.id}>
<tr aria-busy={isBusy || undefined}>
<th scope="row">
<span className="admin-users__email">{user.email}</span>
<span className="admin-users__username">
{user.profileUsername ? `@${user.profileUsername}` : "Без публичного имени"}
</span>
</th>
<td><time dateTime={user.createdAt}>{formatDate(user.createdAt)}</time></td>
<td>
{user.lastLoginAt ? (
<time dateTime={user.lastLoginAt}>{formatDate(user.lastLoginAt)}</time>
) : "Ещё не входил"}
</td>
<td>{user.racesCount}</td>
<td>{user.completedRacesCount}</td>
<td>
<div className="admin-users__actions">
<button aria-label={`Редактировать ${user.email}`} aria-expanded={activePanel === "edit"} aria-controls={activePanel === "edit" ? `admin-panel-${user.id}` : undefined} className="btn btn--secondary" disabled={busyUserId !== null} type="button" onClick={(event) => { triggerRef.current = event.currentTarget; openEdit(user); }}>
Редактировать
</button>
<button aria-label={`Отправить ссылку для сброса пароля ${user.email}`} aria-expanded={activePanel === "reset"} aria-controls={activePanel === "reset" ? `admin-panel-${user.id}` : undefined} className="btn btn--secondary" disabled={busyUserId !== null} type="button" onClick={(event) => { triggerRef.current = event.currentTarget; openConfirmation(user.id, "reset"); }}>
Отправить ссылку
</button>
{!isAdmin ? (
<button aria-label={`Удалить аккаунт ${user.email}`} aria-expanded={activePanel === "delete"} aria-controls={activePanel === "delete" ? `admin-panel-${user.id}` : undefined} className="btn btn--danger" disabled={busyUserId !== null} type="button" onClick={(event) => { triggerRef.current = event.currentTarget; openConfirmation(user.id, "delete"); }}>
Удалить аккаунт
</button>
) : null}
</div>
</td>
</tr>
</Fragment>
);
})}
</tbody>
</table>
</div>
) : 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 (
<section className="admin-users__panel" id={`admin-panel-${user.id}`} aria-labelledby={`admin-panel-title-${user.id}`}>
<p className="admin-users__message admin-users__message--error" role="alert" aria-live="assertive" aria-atomic="true">
{errorMessageText}
</p>
{panel.kind === "edit" && draft ? (
<form className="admin-users__form" onSubmit={(event) => void saveUser(event, user)}>
<h2 className="admin-users__panel-title" id={`admin-panel-title-${user.id}`}>Редактировать {user.email}</h2>
{isAdmin ? <p className="admin-users__note" id={`admin-email-note-${user.id}`}>Email администратора закреплён для сохранения доступа.</p> : null}
<label className="admin-users__field"><span>Email</span><input className="auth-form__input" autoComplete="email" autoFocus={!isAdmin} aria-describedby={isAdmin ? `admin-email-note-${user.id}` : `admin-email-change-note-${user.id}`} disabled={isBusy} name="email" readOnly={isAdmin} required type="email" value={draft.email} onChange={(event) => setDraft({ ...draft, email: event.target.value })} /></label>
{!isAdmin ? <p className="admin-users__note" id={`admin-email-change-note-${user.id}`}>При смене адреса пользователь выйдет из активных сессий и должен будет подтвердить новый email.</p> : null}
<label className="admin-users__field"><span>Публичное имя</span><input className="auth-form__input" autoComplete="off" autoFocus={isAdmin} disabled={isBusy} maxLength={30} minLength={3} name="profileUsername" pattern="[a-z0-9](?:[a-z0-9-]*[a-z0-9])?" placeholder="runner-name" value={draft.profileUsername} onChange={(event) => setDraft({ ...draft, profileUsername: event.target.value.toLowerCase() })} /></label>
<fieldset className="admin-users__visibility"><legend>Публичная страница</legend><label><input checked={draft.isFutureRacesPublic} disabled={isBusy} name="isFutureRacesPublic" type="checkbox" onChange={(event) => setDraft({ ...draft, isFutureRacesPublic: event.target.checked })} />Показывать будущие старты</label><label><input checked={draft.isCompletedRacesPublic} disabled={isBusy} name="isCompletedRacesPublic" type="checkbox" onChange={(event) => setDraft({ ...draft, isCompletedRacesPublic: event.target.checked })} />Показывать завершённые старты и результаты</label></fieldset>
<div className="admin-users__panel-actions"><button className="btn btn--secondary" disabled={isBusy} type="button" onClick={closePanel}>Отменить</button><button className="btn btn--primary" disabled={isBusy} type="submit">{isBusy ? "Сохраняем…" : "Сохранить изменения"}</button></div>
</form>
) : null}
{panel.kind === "reset" ? <section className="admin-users__confirm" aria-labelledby={`admin-panel-title-${user.id}`}><h2 className="admin-users__panel-title" id={`admin-panel-title-${user.id}`}>Отправить ссылку для сброса пароля?</h2><p>Новая ссылка будет отправлена на {user.email}. Предыдущая неиспользованная ссылка перестанет работать.</p><div className="admin-users__panel-actions"><button autoFocus className="btn btn--secondary" disabled={isBusy} type="button" onClick={closePanel}>Отменить</button><button className="btn btn--primary" disabled={isBusy} type="button" onClick={() => void sendPasswordReset(user)}>{isBusy ? "Отправляем…" : "Отправить ссылку"}</button></div></section> : null}
{panel.kind === "delete" ? <section className="admin-users__confirm admin-users__confirm--danger" aria-labelledby={`admin-panel-title-${user.id}`}><h2 className="admin-users__panel-title" id={`admin-panel-title-${user.id}`}>Удалить аккаунт?</h2><p>Аккаунт <strong>{user.email}</strong> и все его забеги будут удалены без возможности восстановления.</p><label className="admin-users__field"><span>Введите точный email для подтверждения</span><input className="auth-form__input" autoComplete="off" autoFocus disabled={isBusy} name="deleteConfirmation" value={deleteConfirmation} onChange={(event) => setDeleteConfirmation(event.target.value)} /></label><div className="admin-users__panel-actions"><button className="btn btn--secondary" disabled={isBusy} type="button" onClick={closePanel}>Отменить</button><button className="btn btn--danger" disabled={isBusy || deleteConfirmation !== user.email} type="button" onClick={() => void deleteUser(user)}>{isBusy ? "Удаляем…" : "Удалить аккаунт и забеги"}</button></div></section> : null}
</section>
);
})() : null}
</section>
);
}
+160
View File
@@ -27,10 +27,12 @@ a {
.app-shell {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto 1fr auto;
}
.app-shell__header {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
@@ -85,6 +87,7 @@ a {
}
.app-shell__main {
min-width: 0;
width: min(1080px, 100%);
margin: 0 auto;
padding: var(--space-6);
@@ -2226,3 +2229,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);
Review

P2 — на мобильном экране таблица растягивает всю страницу вместо локальной прокрутки. В Chromium при viewport 390 px document.documentElement.scrollWidth = 1108 px, контейнер .admin-users__table-scroll = 1026 px, поэтому overflow-x: auto не срабатывает: весь интерфейс, шапка и футер уезжают вправо. Причина — минимальная ширина таблицы 64rem в сочетании с автоматической минимальной шириной grid-элемента .app-shell__main. Проверка с grid-template-columns: minmax(0,1fr) у .app-shell и min-width:0 у main/header возвращает ширину документа к 390 px, таблица прокручивается внутри контейнера. Нужен эквивалентный фикс мобильной раскладки.

P2 — на мобильном экране таблица растягивает всю страницу вместо локальной прокрутки. В Chromium при viewport 390 px `document.documentElement.scrollWidth` = 1108 px, контейнер `.admin-users__table-scroll` = 1026 px, поэтому `overflow-x: auto` не срабатывает: весь интерфейс, шапка и футер уезжают вправо. Причина — минимальная ширина таблицы 64rem в сочетании с автоматической минимальной шириной grid-элемента `.app-shell__main`. Проверка с `grid-template-columns: minmax(0,1fr)` у `.app-shell` и `min-width:0` у main/header возвращает ширину документа к 390 px, таблица прокручивается внутри контейнера. Нужен эквивалентный фикс мобильной раскладки.
}
.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;
}
}