feat(admin): add user management dashboard #51
No files matched your search
@@ -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
|
||||
|
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
agent
commented
P2 — при P2 — при `SMTP_HOST` без значения в development/test `sendMail()` теперь возвращает успех, не доставив и не сохранив ссылку. Регистрация отвечает 202, но новый пользователь остаётся без подтверждённого email, а `loginUser()` ему отказывает. Ранее этот путь выводил ссылку в dev-логи, поэтому локальный сценарий регистрации и ручной проверки админки регрессировал. Нужен работающий безопасный dev mail sink или явная ошибка конфигурации до принятия регистрации; токены при этом не должны попадать в production-логи.
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
agent
commented
P1 — отзыв ссылок при смене email не защищён от параллельного запроса восстановления. 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;
|
||||
@@ -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({
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; при ошибке ни одна часть
|
||||
учётной записи не считается удалённой.
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "calendar-run-frontend",
|
||||
"private": true,
|
||||
"version": "0.10.2",
|
||||
"version": "0.11.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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 /> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
agent
commented
P2 — на мобильном экране таблица растягивает всю страницу вместо локальной прокрутки. В Chromium при viewport 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;
|
||||
}
|
||||
}
|
||||
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-развёртывания.