forked from admin/runners-calendar
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cf4459a6d | ||
|
|
1ce43495dd | ||
|
|
dcf62c882f | ||
|
|
4c77efe76a | ||
|
|
4f61d9a106 | ||
|
|
d2c54614af | ||
|
|
36c2042da5 | ||
|
|
9cde2ec55f | ||
|
|
69cfd3bf3e | ||
|
|
186accdcfb | ||
|
|
880d57dc59 | ||
|
|
570004f5be | ||
|
|
1b0e524e0e | ||
|
|
47f90c5389 | ||
|
|
1b8235fa6e |
No files matched your search
+3
-2
@@ -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
|
||||
|
||||
+165
-18
@@ -3,27 +3,70 @@
|
||||
Все заметки о выпусках Calendar Run. Backend и frontend имеют независимые версии.
|
||||
|
||||
Дата означает дату попадания изменения в `main`; она не заменяет дату production-деплоя.
|
||||
Планируемые версии не вносятся сюда до выпуска — они находятся в [docs/product-backlog.md](docs/product-backlog.md).
|
||||
|
||||
## В работе
|
||||
|
||||
### Backend 1.6.0
|
||||
|
||||
#### Added
|
||||
|
||||
- Управление паролем и сессиями: проверка текущего пароля, список активных сессий, отзыв выбранной или всех остальных сессий.
|
||||
- Фильтрация стартов по статусу, периоду и дистанции; backfill и обновление автоматически получаемых обложек.
|
||||
|
||||
### Frontend 0.9.0
|
||||
|
||||
#### Added
|
||||
|
||||
- Экран «Аккаунт»: подтверждённый email, смена пароля и управление другими активными сессиями.
|
||||
- На дашборде появились фильтруемые «Последние старты» с пагинацией и переходом в старт, ссылки из карточек рекордов и интерактивный график прогресса.
|
||||
- Поиск, фильтры, сортировка и экспорт истории; график прогресса переведён на AG Charts.
|
||||
Локально подготовленные версии помечаются явно; дату `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
|
||||
|
||||
- Фильтрация стартов по статусу, периоду и дистанции; backfill и обновление автоматически получаемых обложек.
|
||||
|
||||
### [1.5.3] — 2026-07-12
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Миграции базы данных автоматически выполняются перед запуском backend-контейнера.
|
||||
|
||||
### [1.5.2] — 2026-07-12
|
||||
|
||||
#### Added
|
||||
|
||||
- Настраиваемое имя публичного профиля и раздельная видимость будущих и завершённых стартов.
|
||||
- Публичные результаты финиша с учётом настроек видимости владельца.
|
||||
|
||||
### [1.5.1] — 2026-07-12
|
||||
|
||||
#### Added
|
||||
|
||||
- Публичные страницы пользователей и API опубликованного календаря стартов.
|
||||
|
||||
### [1.5.0] — 2026-07-12
|
||||
|
||||
#### Added
|
||||
|
||||
- Проверка и смена текущего пароля.
|
||||
- Список активных сессий, отзыв выбранной сессии или всех остальных сессий.
|
||||
|
||||
### [1.4.2] — 2026-07-12
|
||||
|
||||
#### Changed
|
||||
@@ -93,6 +136,91 @@
|
||||
|
||||
## 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.10.1] — 2026-08-13
|
||||
|
||||
#### Changed
|
||||
|
||||
- На странице `/races` завершённые старты сортируются от ранних к поздним; рядом с разделами отображается количество стартов.
|
||||
- График прогресса на дашборде показывает темп в мин/км: шкала идёт от медленного темпа внизу к нулю сверху.
|
||||
- Подписи графика обновлены с времени на темп.
|
||||
|
||||
### [0.10.0] — 2026-08-13
|
||||
|
||||
#### Changed
|
||||
|
||||
- Основные фильтры календаря оставлены в hero-блоке, а расширенные фильтры перенесены в компактную раскрываемую панель со счётчиком активных условий.
|
||||
|
||||
#### Fixed
|
||||
|
||||
- В конфигурацию TypeScript для Vite добавлены DOM-типы.
|
||||
|
||||
### [0.9.3] — 2026-07-13
|
||||
|
||||
#### Changed
|
||||
|
||||
- Фильтры календаря сворачиваются и показывают число активных условий; hero-блок дашборда стал компактнее.
|
||||
|
||||
### [0.9.1] — 2026-07-13
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Зарегистрированы необходимые модули AG Charts для корректного отображения графика.
|
||||
|
||||
### [0.9.0] — 2026-07-13
|
||||
|
||||
#### Added
|
||||
|
||||
- Поиск, фильтры и сортировка истории стартов с синхронизацией параметров в URL.
|
||||
- Экспорт выбранных стартов в CSV и iCalendar.
|
||||
- Интерактивный график прогресса на AG Charts.
|
||||
- Фильтрация стартов по статусу, диапазону дат и дистанции.
|
||||
|
||||
### [0.8.2] — 2026-07-12
|
||||
|
||||
#### Added
|
||||
|
||||
- Имена публичных профилей и отдельные настройки видимости будущих и завершённых стартов.
|
||||
- Отображение опубликованных результатов на публичной странице.
|
||||
|
||||
### [0.8.1] — 2026-07-12
|
||||
|
||||
#### Added
|
||||
|
||||
- Публичная страница пользователя с опубликованным календарём стартов.
|
||||
- Управление видимостью публичного профиля в аккаунте.
|
||||
|
||||
### [0.8.0] — 2026-07-12
|
||||
|
||||
#### Added
|
||||
|
||||
- Экран «Аккаунт»: подтверждённый email, смена пароля и управление активными сессиями.
|
||||
- Фильтруемые «Последние старты» с пагинацией и переходом в старт на дашборде.
|
||||
- Ссылки из карточек рекордов и интерактивный график прогресса.
|
||||
|
||||
### [0.7.1] — 2026-07-12
|
||||
|
||||
#### Changed
|
||||
@@ -145,6 +273,25 @@
|
||||
|
||||
- Редизайн дашборда и визуального представления стартов.
|
||||
|
||||
### [0.4.3] — 2026-04-13
|
||||
|
||||
#### Fixed
|
||||
|
||||
- В календаре скрыты всплывающие карточки для дней без стартов.
|
||||
|
||||
### [0.4.2] — 2026-04-13
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Анимация наведения применяется ко всей строке старта, а не только к внутренней ссылке.
|
||||
|
||||
### [0.4.1] — 2026-04-13
|
||||
|
||||
#### Fixed
|
||||
|
||||
- Старт автоматически получает статус «Пробежал» при вводе корректного времени финиша.
|
||||
- Исправлены переходы с дашборда и поведение списка и календаря стартов.
|
||||
|
||||
### [0.4.0] — 2026-04-13
|
||||
|
||||
#### Added
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
|
||||
## Переменные окружения
|
||||
|
||||
Один шаблон для локальной разработки и для Docker-стека: `**[.env.example](.env.example)`** → скопируйте в `**.env**` в корне репозитория.
|
||||
Один шаблон для локальной разработки и Docker-стека: [.env.example](.env.example) → скопируйте в `.env` в корне репозитория.
|
||||
|
||||
Там же перечислены `**DB_HOST**`, `**DB_PORT**`, `**DB_NAME**`, `**DB_USER**`, `**DB_PASSWORD**` (подключение бэкенда к БД), `**PORT**` / `**API_PORT**` и `**CORS_ORIGIN**`.
|
||||
Там же перечислены `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` (подключение бэкенда к БД), `PORT` / `API_PORT` и `CORS_ORIGIN`.
|
||||
|
||||
## Backend — локально
|
||||
|
||||
1. `cd backend && npm install`
|
||||
1. `cd backend && npm ci`
|
||||
2. Корень: `cp .env.example .env`, задайте `DB_*` (и при необходимости `CORS_ORIGIN`).
|
||||
3. Postgres: из корня `docker compose up -d` (см. `[docker-compose.yml](docker-compose.yml)`) — в compose используются те же `DB_NAME`, `DB_USER`, `DB_PASSWORD` из `.env`.
|
||||
3. Postgres: из корня `docker compose up -d` (см. [docker-compose.yml](docker-compose.yml)) — в compose используются те же `DB_NAME`, `DB_USER`, `DB_PASSWORD` из `.env`.
|
||||
4. `cd backend && npm run db:migrate && npm run seed`
|
||||
5. Dev-режим: `npm run dev`
|
||||
6. Или production: `npm run build && npm start`
|
||||
@@ -22,15 +22,15 @@
|
||||
```bash
|
||||
cd frontend
|
||||
cp .env.example .env
|
||||
npm install
|
||||
npm ci
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Фронт всегда отправляет запросы на относительный префикс `**/api**`. В dev это проксирует Vite на `http://localhost:3001`, в Docker/проде — nginx фронта проксирует на `runners-calendar-backend:3000`. У бэкенда `CORS_ORIGIN` должен совпадать с origin приложения (например `http://localhost:5173`).
|
||||
Фронт всегда отправляет запросы на относительный префикс `/api`. В dev это проксирует Vite на `http://localhost:3001`, в Docker/проде — nginx фронта проксирует на `runners-calendar-backend:3000`. У бэкенда `CORS_ORIGIN` должен совпадать с origin приложения (например `http://localhost:5173`).
|
||||
|
||||
## Docker: backend + frontend рядом с Postgres
|
||||
|
||||
Используйте `[docker-compose.stack.yml](docker-compose.stack.yml)`: общая **внешняя** сеть с контейнером Postgres (как в вашей инфраструктуре). В корне должен быть `**.env`** (из `.env.example`): `DB_HOST` — имя сервиса/контейнера Postgres в этой сети, `DB_PORT=5432`, плюс остальные `DB_*` и `**CORS_ORIGIN=http://localhost:3033**`, если заходите на фронт с хоста на порту 3033.
|
||||
Используйте [docker-compose.stack.yml](docker-compose.stack.yml): общая **внешняя** сеть с контейнером Postgres (как в вашей инфраструктуре). В корне должен быть `.env` (из `.env.example`): `DB_HOST` — имя сервиса/контейнера Postgres в этой сети, `DB_PORT=5432`, плюс остальные `DB_*` и `CORS_ORIGIN=http://localhost:3033`, если заходите на фронт с хоста на порту 3033.
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.stack.yml up -d --build
|
||||
@@ -38,9 +38,11 @@ docker compose -f docker-compose.stack.yml exec runners-calendar-backend node di
|
||||
docker compose -f docker-compose.stack.yml exec runners-calendar-backend node dist/seed.js
|
||||
```
|
||||
|
||||
Фронт в браузере обращается к API по префиксу `**/api**` (nginx в образе фронта проксирует на сервис `runners-calendar-backend` в той же сети).
|
||||
Фронт в браузере обращается к API по префиксу `/api` (nginx в образе фронта проксирует на сервис `runners-calendar-backend` в той же сети).
|
||||
|
||||
## Документация API и бэкенда
|
||||
## Документация
|
||||
|
||||
- [Шпаргалка API для фронта](docs/backend-api-for-frontend.md)
|
||||
- [Эксплуатация backend](docs/backend.md)
|
||||
- [Продуктовый бэклог](docs/product-backlog.md)
|
||||
- [Changelog](CHANGELOG.md)
|
||||
@@ -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;
|
||||
Generated
+2
-2
@@ -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();
|
||||
|
||||
+52
-21
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { Request, Response, Router } from "express";
|
||||
import { PoolClient } from "pg";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import { z } from "zod";
|
||||
import { createResetToken, createVerificationToken, sendResetEmail, sendVerificationEmail } from "../authService";
|
||||
import { requireAdmin } from "../authMiddleware";
|
||||
import { pool } from "../db";
|
||||
import { ADMIN_EMAIL, normalizeEmail } from "../security";
|
||||
import { securityLog } from "../securityLog";
|
||||
|
||||
const router: Router = Router();
|
||||
router.use("/admin", requireAdmin);
|
||||
|
||||
const userIdSchema = z.string().uuid();
|
||||
const profileUsernameSchema = z.string().trim().toLowerCase().min(3).max(30)
|
||||
.regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/);
|
||||
const updateUserSchema = z.object({
|
||||
email: z.string().trim().email().optional(),
|
||||
profileUsername: profileUsernameSchema.nullable().optional(),
|
||||
isFutureRacesPublic: z.boolean().optional(),
|
||||
isCompletedRacesPublic: z.boolean().optional(),
|
||||
}).strict().refine((body) => Object.keys(body).length > 0);
|
||||
|
||||
const resetLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
limit: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
keyGenerator: (req) => `admin-reset:${req.auth!.user.id}`,
|
||||
});
|
||||
|
||||
function validationError(res: Response): void {
|
||||
res.status(400).json({ error: "validation_error", details: ["Invalid request"] });
|
||||
}
|
||||
|
||||
function userId(req: Request, res: Response): string | null {
|
||||
const parsed = userIdSchema.safeParse(req.params.id);
|
||||
if (!parsed.success) {
|
||||
validationError(res);
|
||||
return null;
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
router.get("/admin/users", async (_req: Request, res: Response, next) => {
|
||||
try {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT u.id, u.email, u.email_verified_at, u.profile_username,
|
||||
u.is_future_races_public, u.is_completed_races_public,
|
||||
u.created_at, u.last_login_at,
|
||||
COUNT(r.id)::int AS races_count,
|
||||
COUNT(r.id) FILTER (WHERE r.status = 'completed')::int AS completed_races_count
|
||||
FROM users u
|
||||
LEFT JOIN races r ON r.owner_user_id = u.id
|
||||
GROUP BY u.id
|
||||
ORDER BY u.created_at ASC, u.id ASC
|
||||
`);
|
||||
res.json(rows.map((row) => ({
|
||||
id: row.id,
|
||||
email: row.email,
|
||||
emailVerifiedAt: row.email_verified_at,
|
||||
profileUsername: row.profile_username,
|
||||
isFutureRacesPublic: row.is_future_races_public,
|
||||
isCompletedRacesPublic: row.is_completed_races_public,
|
||||
createdAt: row.created_at,
|
||||
lastLoginAt: row.last_login_at,
|
||||
racesCount: row.races_count,
|
||||
completedRacesCount: row.completed_races_count,
|
||||
})));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch("/admin/users/:id", async (req: Request, res: Response, next) => {
|
||||
const id = userId(req, res);
|
||||
const parsed = updateUserSchema.safeParse(req.body);
|
||||
if (!id || !parsed.success) {
|
||||
if (id && !parsed.success) validationError(res);
|
||||
return;
|
||||
}
|
||||
|
||||
let client: PoolClient | undefined;
|
||||
let verification: { email: string; token: string } | null = null;
|
||||
try {
|
||||
client = await pool.connect();
|
||||
await client.query("BEGIN");
|
||||
const currentResult = await client.query<{ email: string; email_verified_at: Date | string | null }>(
|
||||
"SELECT email, email_verified_at FROM users WHERE id = $1 FOR UPDATE",
|
||||
[id],
|
||||
);
|
||||
const current = currentResult.rows[0];
|
||||
if (!current) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
res.status(404).json({ error: "not_found", details: ["User not found"] });
|
||||
return;
|
||||
}
|
||||
|
||||
const currentEmail = normalizeEmail(current.email);
|
||||
const nextEmail = parsed.data.email === undefined ? currentEmail : normalizeEmail(parsed.data.email);
|
||||
const emailChanged = nextEmail !== currentEmail;
|
||||
if ((currentEmail === ADMIN_EMAIL && emailChanged) || (currentEmail !== ADMIN_EMAIL && nextEmail === ADMIN_EMAIL)) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
res.status(409).json({ error: "protected_admin", details: ["Administrator identity is protected"] });
|
||||
return;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`UPDATE users SET
|
||||
email = CASE WHEN $2 THEN $3 ELSE email END,
|
||||
email_verified_at = CASE WHEN $2 THEN NULL ELSE email_verified_at END,
|
||||
profile_username = CASE WHEN $4 THEN $5 ELSE profile_username END,
|
||||
is_future_races_public = CASE WHEN $6 THEN $7 ELSE is_future_races_public END,
|
||||
is_completed_races_public = CASE WHEN $8 THEN $9 ELSE is_completed_races_public END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[
|
||||
id,
|
||||
emailChanged,
|
||||
nextEmail,
|
||||
parsed.data.profileUsername !== undefined,
|
||||
parsed.data.profileUsername,
|
||||
parsed.data.isFutureRacesPublic !== undefined,
|
||||
parsed.data.isFutureRacesPublic,
|
||||
parsed.data.isCompletedRacesPublic !== undefined,
|
||||
parsed.data.isCompletedRacesPublic,
|
||||
],
|
||||
);
|
||||
|
||||
if (emailChanged) {
|
||||
await client.query("UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", [id]);
|
||||
await client.query("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL", [id]);
|
||||
verification = { email: nextEmail, token: await createVerificationToken(client, id) };
|
||||
}
|
||||
if (verification) await sendVerificationEmail(verification.email, verification.token);
|
||||
await client.query("COMMIT");
|
||||
} catch (error) {
|
||||
if (client) await client.query("ROLLBACK").catch(() => undefined);
|
||||
if (typeof error === "object" && error !== null && (error as { code?: string }).code === "23505") {
|
||||
res.status(409).json({ error: "conflict", details: ["Email or username is already in use"] });
|
||||
return;
|
||||
}
|
||||
next(error);
|
||||
return;
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
|
||||
securityLog("admin.user_updated", { actorUserId: req.auth!.user.id, targetUserId: id });
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
router.post("/admin/users/:id/password-reset", resetLimiter, async (req: Request, res: Response, next) => {
|
||||
const id = userId(req, res);
|
||||
if (!id) return;
|
||||
|
||||
let client: PoolClient | undefined;
|
||||
let email: string;
|
||||
let token: string;
|
||||
try {
|
||||
client = await pool.connect();
|
||||
await client.query("BEGIN");
|
||||
const result = await client.query<{ email: string }>("SELECT email FROM users WHERE id = $1 FOR UPDATE", [id]);
|
||||
if (!result.rows[0]) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
res.status(404).json({ error: "not_found", details: ["User not found"] });
|
||||
return;
|
||||
}
|
||||
email = result.rows[0].email;
|
||||
token = await createResetToken(client, id);
|
||||
await sendResetEmail(email, token);
|
||||
await client.query("COMMIT");
|
||||
} catch (error) {
|
||||
if (client) await client.query("ROLLBACK").catch(() => undefined);
|
||||
next(error);
|
||||
return;
|
||||
} finally {
|
||||
client?.release();
|
||||
}
|
||||
|
||||
securityLog("admin.password_reset_requested", { actorUserId: req.auth!.user.id, targetUserId: id });
|
||||
res.status(202).json({ ok: true });
|
||||
});
|
||||
|
||||
router.delete("/admin/users/:id", async (req: Request, res: Response, next) => {
|
||||
const id = userId(req, res);
|
||||
if (!id) return;
|
||||
if (id === req.auth!.user.id) {
|
||||
res.status(409).json({ error: "protected_admin", details: ["Administrator account cannot be deleted"] });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await pool.query(
|
||||
"DELETE FROM users WHERE id = $1 AND LOWER(BTRIM(email)) <> $2 RETURNING id",
|
||||
[id, ADMIN_EMAIL],
|
||||
);
|
||||
if (!result.rows[0]) {
|
||||
res.status(404).json({ error: "not_found", details: ["User not found"] });
|
||||
return;
|
||||
}
|
||||
securityLog("admin.user_deleted", { actorUserId: req.auth!.user.id, targetUserId: id });
|
||||
res.status(204).end();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+197
-1
@@ -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.
@@ -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`
|
||||
|
||||
+23
-2
@@ -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
|
||||
```
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "calendar-run-frontend",
|
||||
"version": "0.9.3",
|
||||
"version": "0.11.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "calendar-run-frontend",
|
||||
"version": "0.9.3",
|
||||
"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.9.3",
|
||||
"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 /> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -14,15 +14,13 @@ type PaceDatum = {
|
||||
id: string;
|
||||
date: string;
|
||||
dateLabel: string;
|
||||
minutes: number;
|
||||
paceSeconds: number;
|
||||
race: Race;
|
||||
};
|
||||
|
||||
function formatAxisTime(minutes: number): string {
|
||||
const totalSeconds = Math.round(minutes * 60);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const rest = totalSeconds % 3600;
|
||||
return hours ? `${hours}:${String(Math.floor(rest / 60)).padStart(2, "0")}` : `${Math.floor(rest / 60)}:${String(rest % 60).padStart(2, "0")}`;
|
||||
function formatAxisPace(seconds: number): string {
|
||||
const roundedSeconds = Math.round(seconds);
|
||||
return `${Math.floor(roundedSeconds / 60)}:${String(roundedSeconds % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatShortDate(date: string): string {
|
||||
@@ -36,7 +34,7 @@ function cssToken(name: string, fallback: string): string {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
|
||||
}
|
||||
|
||||
/** Линейный график: время финиша по завершённым стартам выбранной дистанции. */
|
||||
/** Линейный график: темп по завершённым стартам выбранной дистанции. */
|
||||
export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.Element {
|
||||
const navigate = useNavigate();
|
||||
const series = useMemo<PaceDatum[]>(
|
||||
@@ -47,7 +45,7 @@ export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.
|
||||
id: race.id,
|
||||
date: race.date,
|
||||
dateLabel: formatShortDate(race.date),
|
||||
minutes: parseFinishTimeToSeconds(race.finishTime)! / 60,
|
||||
paceSeconds: parseFinishTimeToSeconds(race.finishTime)! / race.distanceKm,
|
||||
race,
|
||||
})),
|
||||
[distanceKm, races],
|
||||
@@ -66,16 +64,18 @@ export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.
|
||||
y: {
|
||||
type: "number",
|
||||
position: "left",
|
||||
title: { text: "Время" },
|
||||
label: { formatter: ({ value }) => formatAxisTime(Number(value)) },
|
||||
reverse: true,
|
||||
min: 0,
|
||||
title: { text: "Темп, мин/км" },
|
||||
label: { formatter: ({ value }) => formatAxisPace(Number(value)) },
|
||||
},
|
||||
},
|
||||
series: [{
|
||||
type: "line",
|
||||
xKey: "dateLabel",
|
||||
xName: "Дата старта",
|
||||
yKey: "minutes",
|
||||
yName: "Время",
|
||||
yKey: "paceSeconds",
|
||||
yName: "Темп",
|
||||
stroke: cssToken("--color-accent", "#1168d8"),
|
||||
strokeWidth: 3,
|
||||
marker: {
|
||||
@@ -106,7 +106,7 @@ export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pace-chart" role="group" aria-label="Динамика времени на дистанции. Выберите точку, чтобы открыть старт.">
|
||||
<div className="pace-chart" role="group" aria-label="Динамика темпа на дистанции. Выберите точку, чтобы открыть старт.">
|
||||
<AgCharts options={options} />
|
||||
<p className="pace-chart__hint">Наведите на точку или выберите её клавиатурой.</p>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -303,10 +303,10 @@ export function DashboardPage(): JSX.Element {
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<section className="dashboard-section" aria-label="Динамика времени на дистанции">
|
||||
<h2 className="dashboard-section__title">Прогресс по времени</h2>
|
||||
<section className="dashboard-section" aria-label="Динамика темпа на дистанции">
|
||||
<h2 className="dashboard-section__title">Прогресс по темпу</h2>
|
||||
<p className="dashboard-section__intro">
|
||||
Линия по завершённым стартам выбранной дистанции: выше — лучше время (короче гонка).
|
||||
Линия по завершённым стартам выбранной дистанции: выше — темп быстрее (меньше минут на километр).
|
||||
</p>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Дистанция для графика</span>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
getRaceStatusClassName,
|
||||
getRaceStatusLabel,
|
||||
parseRaceDate,
|
||||
sortByDateDesc,
|
||||
downloadTextFile,
|
||||
racesToCsv,
|
||||
racesToIcs,
|
||||
@@ -36,6 +37,7 @@ const VIEW_STORAGE_KEY = "races-view-mode";
|
||||
type ViewMode = "list" | "calendar";
|
||||
type RaceListTab = "upcoming" | "completed";
|
||||
type RaceSort = "dateAsc" | "dateDesc" | "titleAsc" | "distanceAsc";
|
||||
type RaceSortOption = RaceSort | "";
|
||||
|
||||
function yearSelectOptions(races: Race[], selectedYear = ""): number[] {
|
||||
const current = new Date().getFullYear();
|
||||
@@ -64,7 +66,10 @@ function RaceList(props: { title: string; races: Race[]; variant: RaceListTab })
|
||||
|
||||
return (
|
||||
<section className={`race-list race-list--${variant}`} aria-label={title}>
|
||||
<h2 className="race-list__title">{title}</h2>
|
||||
<h2 className="race-list__title">
|
||||
{title}
|
||||
<span className="race-list__count">{races.length}</span>
|
||||
</h2>
|
||||
{races.length > 0 ? (
|
||||
<ul className="race-list__items">
|
||||
{races.map((race) => {
|
||||
@@ -142,11 +147,11 @@ export function RacesPage(): JSX.Element {
|
||||
const [distanceMin, setDistanceMin] = useState<string>(() => searchParams.get("distanceMin") ?? "");
|
||||
const [distanceMax, setDistanceMax] = useState<string>(() => searchParams.get("distanceMax") ?? "");
|
||||
const [searchQuery, setSearchQuery] = useState<string>(() => searchParams.get("q") ?? "");
|
||||
const [sortOrder, setSortOrder] = useState<RaceSort>(() => (searchParams.get("sort") as RaceSort | null) ?? "dateAsc");
|
||||
const [sortOrder, setSortOrder] = useState<RaceSortOption>(() => (searchParams.get("sort") as RaceSort | null) ?? "");
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(() => searchParams.get("view") === "calendar" ? "calendar" : readInitialViewMode());
|
||||
const [activeListTab, setActiveListTab] = useState<RaceListTab>("upcoming");
|
||||
const [isFiltersOpen, setIsFiltersOpen] = useState<boolean>(() =>
|
||||
["year", "month", "status", "dateFrom", "dateTo", "distanceMin", "distanceMax", "q", "sort"].some((key) => searchParams.has(key)),
|
||||
const [isAdvancedFiltersOpen, setIsAdvancedFiltersOpen] = useState<boolean>(() =>
|
||||
["dateFrom", "dateTo", "distanceMin", "distanceMax", "sort"].some((key) => searchParams.has(key)),
|
||||
);
|
||||
|
||||
const setViewModePersist = useCallback((mode: ViewMode) => {
|
||||
@@ -259,7 +264,7 @@ export function RacesPage(): JSX.Element {
|
||||
if (distanceMin) next.set("distanceMin", distanceMin);
|
||||
if (distanceMax) next.set("distanceMax", distanceMax);
|
||||
if (searchQuery) next.set("q", searchQuery);
|
||||
if (sortOrder !== "dateAsc") next.set("sort", sortOrder);
|
||||
if (sortOrder) next.set("sort", sortOrder);
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [dateFrom, dateTo, distanceMax, distanceMin, monthFilter, searchQuery, setSearchParams, sortOrder, statusFilter, viewMode, yearFilter]);
|
||||
|
||||
@@ -277,9 +282,11 @@ export function RacesPage(): JSX.Element {
|
||||
const { upcoming, completed } = useMemo(
|
||||
() => ({
|
||||
upcoming: filteredRaces.filter((race) => race.status !== "completed"),
|
||||
completed: filteredRaces.filter((race) => race.status === "completed"),
|
||||
completed: sortOrder
|
||||
? filteredRaces.filter((race) => race.status === "completed")
|
||||
: sortByDateDesc(filteredRaces.filter((race) => race.status === "completed")),
|
||||
}),
|
||||
[filteredRaces],
|
||||
[filteredRaces, sortOrder],
|
||||
);
|
||||
const statusMessage = useMemo(() => {
|
||||
if (errorMessage && !isLoading) {
|
||||
@@ -300,16 +307,12 @@ export function RacesPage(): JSX.Element {
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const activeFilterCount = [
|
||||
viewMode === "list" ? yearFilter : "",
|
||||
monthFilter,
|
||||
statusFilter,
|
||||
searchQuery,
|
||||
const activeAdvancedFilterCount = [
|
||||
dateFrom,
|
||||
dateTo,
|
||||
distanceMin,
|
||||
distanceMax,
|
||||
sortOrder !== "dateAsc" ? sortOrder : "",
|
||||
sortOrder,
|
||||
].filter(Boolean).length;
|
||||
|
||||
if (errorMessage && races.length === 0 && !isLoading) {
|
||||
@@ -323,7 +326,7 @@ export function RacesPage(): JSX.Element {
|
||||
|
||||
return (
|
||||
<section className="page page--races">
|
||||
<section className="races-hero" aria-label="Календарь стартов">
|
||||
<section className={`races-hero${isAdvancedFiltersOpen ? " races-hero--filters-open" : ""}`} aria-label="Календарь стартов">
|
||||
<div className="races-hero__content">
|
||||
<p className="races-hero__eyebrow">Сезонная афиша</p>
|
||||
<h1 className="page__title">Календарь стартов</h1>
|
||||
@@ -345,94 +348,156 @@ export function RacesPage(): JSX.Element {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<details
|
||||
className="races-hero__filters"
|
||||
open={isFiltersOpen}
|
||||
onToggle={(event) => setIsFiltersOpen(event.currentTarget.open)}
|
||||
>
|
||||
<summary className="races-hero__filters-summary">
|
||||
<span>Фильтры{activeFilterCount > 0 ? ` · ${activeFilterCount} активн.` : ""}</span>
|
||||
</summary>
|
||||
<div className="races-filter" role="search" aria-label="Фильтр по дате">
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Год</span>
|
||||
<select
|
||||
className="races-filter__select"
|
||||
value={viewMode === "list" ? yearFilter : yearFilter || String(displayYear)}
|
||||
onChange={(event) => {
|
||||
setYearFilter(event.target.value);
|
||||
}}
|
||||
{!isAdvancedFiltersOpen ? (
|
||||
<div className="races-hero__filters">
|
||||
<div className="races-filter" role="search" aria-label="Основные фильтры">
|
||||
<div className="races-filter__primary">
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Год</span>
|
||||
<select
|
||||
className="races-filter__select"
|
||||
value={viewMode === "list" ? yearFilter : yearFilter || String(displayYear)}
|
||||
onChange={(event) => {
|
||||
setYearFilter(event.target.value);
|
||||
}}
|
||||
>
|
||||
{viewMode === "list" ? <option value="">Все года</option> : null}
|
||||
{yearSelectOptions(races, yearFilter).map((y) => (
|
||||
<option key={y} value={String(y)}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Месяц</span>
|
||||
<select
|
||||
className="races-filter__select"
|
||||
value={monthFilter}
|
||||
onChange={(event) => {
|
||||
setMonthFilter(event.target.value);
|
||||
}}
|
||||
>
|
||||
{MONTH_OPTIONS.map((opt) => (
|
||||
<option key={opt.value || "all"} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Статус</span>
|
||||
<select className="races-filter__select" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as RaceStatus | "")}>
|
||||
<option value="">Все статусы</option>
|
||||
<option value="planned">Планирую</option>
|
||||
<option value="registered">Зарегистрирован</option>
|
||||
<option value="completed">Пробежал</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Название</span>
|
||||
<input className="races-filter__input" type="search" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} placeholder="Название старта" />
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="races-filter__advanced-summary"
|
||||
aria-expanded={false}
|
||||
onClick={() => setIsAdvancedFiltersOpen(true)}
|
||||
>
|
||||
{viewMode === "list" ? <option value="">Все года</option> : null}
|
||||
{yearSelectOptions(races, yearFilter).map((y) => (
|
||||
<option key={y} value={String(y)}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Месяц</span>
|
||||
<select
|
||||
className="races-filter__select"
|
||||
value={monthFilter}
|
||||
onChange={(event) => {
|
||||
setMonthFilter(event.target.value);
|
||||
}}
|
||||
>
|
||||
{MONTH_OPTIONS.map((opt) => (
|
||||
<option key={opt.value || "all"} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Статус</span>
|
||||
<select className="races-filter__select" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as RaceStatus | "")}>
|
||||
<option value="">Все статусы</option>
|
||||
<option value="planned">Планирую</option>
|
||||
<option value="registered">Зарегистрирован</option>
|
||||
<option value="completed">Пробежал</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Поиск</span>
|
||||
<input className="races-filter__input" type="search" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} placeholder="Название старта" />
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Дата от</span>
|
||||
<input className="races-filter__input" type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} />
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Дата до</span>
|
||||
<input className="races-filter__input" type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} />
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Дистанция от, км</span>
|
||||
<input className="races-filter__input" type="number" min="0.001" step="0.001" value={distanceMin} onChange={(event) => setDistanceMin(event.target.value)} />
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Дистанция до, км</span>
|
||||
<input className="races-filter__input" type="number" min="0.001" step="0.001" value={distanceMax} onChange={(event) => setDistanceMax(event.target.value)} />
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Сортировка</span>
|
||||
<select className="races-filter__select" value={sortOrder} onChange={(event) => setSortOrder(event.target.value as RaceSort)}>
|
||||
<option value="dateAsc">Дата: сначала ранние</option>
|
||||
<option value="dateDesc">Дата: сначала поздние</option>
|
||||
<option value="titleAsc">Название: А–Я</option>
|
||||
<option value="distanceAsc">Дистанция: по возрастанию</option>
|
||||
</select>
|
||||
</label>
|
||||
<span>Дополнительные фильтры{activeAdvancedFilterCount > 0 ? ` · ${activeAdvancedFilterCount} активн.` : ""}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="races-filter__actions">
|
||||
<button type="button" className="btn btn--secondary" disabled={filteredRaces.length === 0} onClick={() => downloadTextFile("calendar-run.csv", racesToCsv(filteredRaces), "text/csv;charset=utf-8")}>Скачать CSV</button>
|
||||
<button type="button" className="btn btn--secondary" disabled={filteredRaces.length === 0} onClick={() => downloadTextFile("calendar-run.ics", racesToIcs(filteredRaces), "text/calendar;charset=utf-8")}>Скачать календарь</button>
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{isAdvancedFiltersOpen ? (
|
||||
<section className="races-filter-panel" aria-label="Расширенные фильтры стартов">
|
||||
<div className="races-filter-panel__header">
|
||||
<div>
|
||||
<p className="races-filter-panel__eyebrow">Расширенный поиск</p>
|
||||
<h2 className="races-filter-panel__title">Фильтры стартов</h2>
|
||||
</div>
|
||||
<button type="button" className="btn btn--secondary races-filter-panel__close" aria-expanded={true} onClick={() => setIsAdvancedFiltersOpen(false)}>
|
||||
Свернуть фильтры
|
||||
</button>
|
||||
</div>
|
||||
<div className="races-filter races-filter--panel" role="search" aria-label="Все фильтры стартов">
|
||||
<div className="races-filter__panel-fields">
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Год</span>
|
||||
<select
|
||||
className="races-filter__select"
|
||||
value={viewMode === "list" ? yearFilter : yearFilter || String(displayYear)}
|
||||
onChange={(event) => setYearFilter(event.target.value)}
|
||||
>
|
||||
{viewMode === "list" ? <option value="">Все года</option> : null}
|
||||
{yearSelectOptions(races, yearFilter).map((y) => (
|
||||
<option key={y} value={String(y)}>
|
||||
{y}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Месяц</span>
|
||||
<select className="races-filter__select" value={monthFilter} onChange={(event) => setMonthFilter(event.target.value)}>
|
||||
{MONTH_OPTIONS.map((opt) => (
|
||||
<option key={opt.value || "all"} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Статус</span>
|
||||
<select className="races-filter__select" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as RaceStatus | "")}>
|
||||
<option value="">Все статусы</option>
|
||||
<option value="planned">Планирую</option>
|
||||
<option value="registered">Зарегистрирован</option>
|
||||
<option value="completed">Пробежал</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Название</span>
|
||||
<input className="races-filter__input" type="search" value={searchQuery} onChange={(event) => setSearchQuery(event.target.value)} placeholder="Название старта" />
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Дата от</span>
|
||||
<input className="races-filter__input" type="date" value={dateFrom} onChange={(event) => setDateFrom(event.target.value)} />
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Дата до</span>
|
||||
<input className="races-filter__input" type="date" value={dateTo} onChange={(event) => setDateTo(event.target.value)} />
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Дистанция от, км</span>
|
||||
<input className="races-filter__input" type="number" min="0.001" step="0.001" value={distanceMin} onChange={(event) => setDistanceMin(event.target.value)} />
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Дистанция до, км</span>
|
||||
<input className="races-filter__input" type="number" min="0.001" step="0.001" value={distanceMax} onChange={(event) => setDistanceMax(event.target.value)} />
|
||||
</label>
|
||||
<label className="races-filter__field">
|
||||
<span className="races-filter__label">Сортировка</span>
|
||||
<select className="races-filter__select" value={sortOrder} onChange={(event) => setSortOrder(event.target.value as RaceSortOption)}>
|
||||
<option value="">По умолчанию</option>
|
||||
<option value="dateAsc">Дата: сначала ранние</option>
|
||||
<option value="dateDesc">Дата: сначала поздние</option>
|
||||
<option value="titleAsc">Название: А–Я</option>
|
||||
<option value="distanceAsc">Дистанция: по возрастанию</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="races-filter__actions">
|
||||
<button type="button" className="btn btn--secondary" disabled={filteredRaces.length === 0} onClick={() => downloadTextFile("calendar-run.csv", racesToCsv(filteredRaces), "text/csv;charset=utf-8")}>Скачать CSV</button>
|
||||
<button type="button" className="btn btn--secondary" disabled={filteredRaces.length === 0} onClick={() => downloadTextFile("calendar-run.ics", racesToIcs(filteredRaces), "text/calendar;charset=utf-8")}>Скачать календарь</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<div className="races-status" aria-live="polite">
|
||||
<p
|
||||
className={statusClassName}
|
||||
|
||||
+287
-10
@@ -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);
|
||||
@@ -370,10 +373,19 @@ a {
|
||||
}
|
||||
|
||||
.race-list__title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--space-2);
|
||||
margin: 0 0 var(--space-4);
|
||||
font-size: var(--font-size-h2);
|
||||
}
|
||||
|
||||
.race-list__count {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.race-list__items {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -1564,22 +1576,60 @@ body {
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.races-hero__filters-summary {
|
||||
.races-hero--filters-open {
|
||||
grid-template-columns: 1fr;
|
||||
min-height: 15rem;
|
||||
}
|
||||
|
||||
.races-hero--filters-open .races-hero__content {
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.races-hero__filters .races-filter {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.races-filter__primary,
|
||||
.races-filter__advanced-fields {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.races-filter__primary {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.races-filter__primary .races-filter__field,
|
||||
.races-filter__advanced-fields .races-filter__field,
|
||||
.races-filter__panel-fields .races-filter__field {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.races-filter__advanced-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
margin-top: var(--space-5);
|
||||
padding-top: var(--space-4);
|
||||
padding-right: 0;
|
||||
padding-bottom: 0;
|
||||
padding-left: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.2);
|
||||
gap: var(--space-3);
|
||||
color: #ffffff;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
text-align: left;
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
border-left: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.races-hero__filters-summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.races-hero__filters-summary::after {
|
||||
.races-filter__advanced-summary::after {
|
||||
content: "+";
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1593,17 +1643,62 @@ body {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.races-hero__filters[open] .races-hero__filters-summary::after {
|
||||
.races-filter__advanced[open] .races-filter__advanced-summary::after {
|
||||
content: "−";
|
||||
}
|
||||
|
||||
.races-hero__filters-summary:focus-visible {
|
||||
.races-filter__advanced-summary:focus-visible {
|
||||
outline: 2px solid var(--color-lime);
|
||||
outline-offset: var(--space-1);
|
||||
}
|
||||
|
||||
.races-hero__filters .races-filter {
|
||||
.races-filter-panel {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
margin-top: var(--space-5);
|
||||
padding: var(--space-6);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.races-filter-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.races-filter-panel__eyebrow {
|
||||
margin: 0 0 var(--space-1);
|
||||
color: var(--color-accent);
|
||||
font-size: var(--font-size-caption);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.races-filter-panel__title {
|
||||
margin: 0;
|
||||
color: var(--color-bg-deep);
|
||||
font-size: var(--font-size-h2);
|
||||
}
|
||||
|
||||
.races-filter-panel .races-filter {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.races-filter__panel-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.races-filter-panel .races-filter__select,
|
||||
.races-filter-panel .races-filter__input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.races-hero__filters .races-filter__label {
|
||||
@@ -1620,6 +1715,10 @@ body {
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
}
|
||||
|
||||
.races-filter-panel .races-filter__label {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.races-view-toggle {
|
||||
border-color: rgba(255, 255, 255, 0.24);
|
||||
background: rgba(7, 25, 39, 0.28);
|
||||
@@ -2048,6 +2147,10 @@ body {
|
||||
background-position: 62% center;
|
||||
}
|
||||
|
||||
.races-filter__panel-fields {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.race-details-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -2080,6 +2183,19 @@ body {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.races-filter-panel {
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.races-filter-panel__header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.races-filter-panel__close {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dashboard-hero__title {
|
||||
font-size: 3rem;
|
||||
}
|
||||
@@ -2097,6 +2213,10 @@ body {
|
||||
.races-cal__year {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.races-filter__panel-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -2109,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);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020"],
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
|
||||
Reference in new issue
Block a user