v1.37 — аутентификация через логин и пароль
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
# Forbidden Stars — учёт партий
|
# Forbidden Stars — учёт партий
|
||||||
|
|
||||||
Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**:
|
Мобильное веб-приложение для учёта партий настольной игры **Forbidden Stars**:
|
||||||
профили игроков (вход через Telegram, пока — dev-заглушка), группы, создание партий
|
профили игроков (вход по логину и паролю или через Telegram), группы, создание партий
|
||||||
с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель.
|
с рандомом фракций и фильтром по дополнениям группы, статистика и общий топ, админ-панель.
|
||||||
|
|
||||||
- **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite
|
- **Бэкенд / ядро + API:** Python · FastAPI · SQLModel · SQLite
|
||||||
@@ -91,8 +91,8 @@ npm run gen:api # сгенерирует типы из живог
|
|||||||
npm run dev # http://127.0.0.1:5173 или http://localhost:5173 (оба стека)
|
npm run dev # http://127.0.0.1:5173 или http://localhost:5173 (оба стека)
|
||||||
```
|
```
|
||||||
|
|
||||||
Вход в dev-режиме — экран `/login`: в деве доступны оба метода (Telegram + вход по нику),
|
Вход в dev-режиме — экран `/login`: логин/пароль, Telegram и вход по нику без пароля (stub);
|
||||||
в проде — только Telegram (см. раздел «Аутентификация»).
|
в проде stub нет (см. раздел «Аутентификация»).
|
||||||
|
|
||||||
## Production (Docker на Pi)
|
## Production (Docker на Pi)
|
||||||
|
|
||||||
@@ -108,7 +108,7 @@ FastAPI отдаёт собранный SPA и API с одного origin. Ми
|
|||||||
## Test — локальный прод-клон в контейнере
|
## Test — локальный прод-клон в контейнере
|
||||||
|
|
||||||
Тот же образ и поведение, что и прод (FastAPI отдаёт SPA, БД на томе, вход игроков
|
Тот же образ и поведение, что и прод (FastAPI отдаёт SPA, БД на томе, вход игроков
|
||||||
только через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi.
|
по логину/паролю или через Telegram), но на своей машине — для проверки прод-сборки до выката на Pi.
|
||||||
Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000).
|
Изолированные тома и порт **8080** (не конфликтует с dev-uvicorn на :8000).
|
||||||
|
|
||||||
Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`.
|
Проще всего — через лаунчер: поставить `APP_ENV=test` в `.env` и запустить `.\run.ps1`.
|
||||||
@@ -123,8 +123,8 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
|||||||
- Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри
|
- Читает **тот же `.env`**, что dev/prod (отдельного `.env.test` больше нет); внутри
|
||||||
контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`).
|
контейнера `APP_ENV` форсится в `test` (см. `docker-compose.test.yml`).
|
||||||
- Данные — на отдельных томах `db-data-test` / `uploads-data-test` (не пересекаются с dev и Pi).
|
- Данные — на отдельных томах `db-data-test` / `uploads-data-test` (не пересекаются с dev и Pi).
|
||||||
- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; вход **игроков** —
|
- Вход: **админ-панель** (`/admin/login`) работает сразу по логину/паролю; **игроки** —
|
||||||
только через Telegram (нужен бот + публичный HTTPS/туннель на `localhost:8080`).
|
по логину/паролю сразу, через Telegram — при боте и публичном HTTPS/туннеле на `localhost:8080`.
|
||||||
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
||||||
и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало
|
и docker compose (test/prod — `$` = подстановка переменной). Чтобы значение совпадало
|
||||||
везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так:
|
везде, в `SECRET_KEY`/`ADMIN_PASSWORD` не должно быть `$`. Удобно генерировать так:
|
||||||
@@ -136,9 +136,17 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
|||||||
|
|
||||||
| | dev | test / prod |
|
| | dev | test / prod |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Telegram Login Widget | ✓ | ✓ (единственный) |
|
| Логин (= ник) и пароль | ✓ | ✓ (основной) |
|
||||||
| Вход по нику (stub) | ✓ | ✗ (физически отсутствует) |
|
| Telegram Login Widget | ✓ | ✓ |
|
||||||
|
| Вход по нику без пароля (stub) | ✓ | ✗ (физически отсутствует) |
|
||||||
|
|
||||||
|
- **Логин и пароль** (`app/auth/password.py`) — основной вход. Логин — это ник игрока
|
||||||
|
(смена ника меняет логин). Пароль: от 8 символов, не длиннее 72 байт, хранится bcrypt.
|
||||||
|
От перебора — окно 15 минут в памяти процесса: 5 неудач на пару «IP + логин» и 20 на IP,
|
||||||
|
дальше `429 TOO_MANY_ATTEMPTS`. Игрок без пароля (из Telegram или созданный до паролей)
|
||||||
|
после входа видит обязательное окно «Задайте пароль». В профиле пароль меняется (нужен
|
||||||
|
текущий) и привязывается Telegram (ник не меняется). Забытый пароль задаёт админ
|
||||||
|
на вкладке аккаунтов — почту приложение не хранит.
|
||||||
- **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:**
|
- **Stub-вход (по нику)** — только для разработки. Его код **физически не попадает в прод:**
|
||||||
файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из
|
файлы `backend/app/auth/dev_stub.py` и `backend/app/routers/dev_auth.py` исключены из
|
||||||
Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development`
|
Docker-образа (`.dockerignore`), роутер подключается лишь при `APP_ENV=development`
|
||||||
@@ -165,7 +173,7 @@ docker compose -f docker-compose.test.yml down -v # остановить и с
|
|||||||
| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) |
|
| Env-файл | единый `.env` | единый `.env` | единый `.env` (на Pi) |
|
||||||
| Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 |
|
| Раздача SPA | Vite (HMR), :5173 | FastAPI, :8080 | FastAPI, :8000 |
|
||||||
| База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) |
|
| База данных | `backend/data/dev/…` | том `db-data-test` (`/data`) | том `db-data` (`/data`) |
|
||||||
| Вход игроков | Telegram + ник (stub) | только Telegram | только Telegram |
|
| Вход игроков | пароль + Telegram + ник (stub) | пароль + Telegram | пароль + Telegram |
|
||||||
|
|
||||||
- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что
|
- **Один `.env` на машину** в корне (рядом с `.env.example`). `APP_ENV` в нём решает, что
|
||||||
запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда
|
запустит лаунчер (`development`/`test`); прод-контейнер это значение **игнорирует** и всегда
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Пользователь: версия сессий (token_version) для отзыва JWT.
|
||||||
|
|
||||||
|
Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) -> no-op;
|
||||||
|
на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite).
|
||||||
|
|
||||||
|
Инкремент token_version отзывает все ранее выданные токены пользователя (см. auth/deps, #57).
|
||||||
|
|
||||||
|
Revision ID: 0013_user_token_version
|
||||||
|
Revises: 0012_match_finish_draft
|
||||||
|
Create Date: 2026-09-13
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0013_user_token_version"
|
||||||
|
down_revision: Union[str, None] = "0012_match_finish_draft"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("users")}
|
||||||
|
with op.batch_alter_table("users") as b:
|
||||||
|
if "token_version" not in cols:
|
||||||
|
b.add_column(
|
||||||
|
sa.Column(
|
||||||
|
"token_version", sa.Integer(), nullable=False, server_default="0"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
cols = {c["name"] for c in insp.get_columns("users")}
|
||||||
|
with op.batch_alter_table("users") as b:
|
||||||
|
if "token_version" in cols:
|
||||||
|
b.drop_column("token_version")
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
"""Вход администратора под защитой от перебора (#56).
|
||||||
|
|
||||||
|
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
|
||||||
|
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
|
||||||
|
Сервис остаётся чистым от инфраструктуры лимитов. Пароль администратора — единственный
|
||||||
|
барьер к полному контролю приложения, поэтому перебор здесь ограничиваем строже игроцкого.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.core.errors import InvalidCredentialsError
|
||||||
|
from app.core.ratelimit import login_throttle
|
||||||
|
from app.models import User
|
||||||
|
from app.services import admin_service
|
||||||
|
|
||||||
|
# Неудач за окно LoginThrottle (15 минут):
|
||||||
|
_PAIR_LIMIT = 5 # на пару «IP + логин» — против перебора пароля с одного адреса
|
||||||
|
_IP_LIMIT = 20 # на IP — против перебора по разным логинам с одного адреса
|
||||||
|
# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно
|
||||||
|
# упирается в этот предел. Щедрее пары, чтобы случайный поток ошибок не запирал вход
|
||||||
|
# админа насовсем (лимит на аккаунт — вектор lockout-DoS, поэтому не слишком строгий).
|
||||||
|
_ACCOUNT_LIMIT = 50
|
||||||
|
|
||||||
|
|
||||||
|
def _keys(ip: str, username: str) -> dict[str, int]:
|
||||||
|
pair_key = f"admin-login:{ip}:{username.casefold()}"
|
||||||
|
return {
|
||||||
|
pair_key: _PAIR_LIMIT,
|
||||||
|
f"admin-login-ip:{ip}": _IP_LIMIT,
|
||||||
|
f"admin-login-user:{username.casefold()}": _ACCOUNT_LIMIT,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def login_admin(session: Session, username: str, password: str, ip: str | None) -> User:
|
||||||
|
"""authenticate_admin под защитой от перебора. Сессию открывает вызывающий."""
|
||||||
|
username = (username or "").strip()
|
||||||
|
ip = ip or "unknown"
|
||||||
|
limits = _keys(ip, username)
|
||||||
|
login_throttle.check(limits)
|
||||||
|
try:
|
||||||
|
admin = admin_service.authenticate_admin(session, username, password)
|
||||||
|
except InvalidCredentialsError:
|
||||||
|
login_throttle.fail(limits)
|
||||||
|
raise
|
||||||
|
# Успех: снимаем счётчики этого аккаунта, чтобы законный вход не копил лимит.
|
||||||
|
for key in limits:
|
||||||
|
login_throttle.reset(key)
|
||||||
|
return admin
|
||||||
@@ -21,9 +21,14 @@ def get_current_user(
|
|||||||
payload = security.decode_token(token, security.AUDIENCE_USER)
|
payload = security.decode_token(token, security.AUDIENCE_USER)
|
||||||
except jwt.PyJWTError as exc: # noqa: F841
|
except jwt.PyJWTError as exc: # noqa: F841
|
||||||
raise AuthError("Сессия недействительна.")
|
raise AuthError("Сессия недействительна.")
|
||||||
|
if security.is_session_revoked(payload):
|
||||||
|
raise AuthError("Сессия недействительна.")
|
||||||
user = session.get(User, int(payload["sub"]))
|
user = session.get(User, int(payload["sub"]))
|
||||||
if user is None or not user.is_active:
|
if user is None or not user.is_active:
|
||||||
raise AuthError("Сессия недействительна.")
|
raise AuthError("Сессия недействительна.")
|
||||||
|
if int(payload.get("ver", 0)) != int(user.token_version or 0):
|
||||||
|
# Пароль сменён/сброшен после выдачи токена — прежние сессии отозваны (#57).
|
||||||
|
raise AuthError("Сессия недействительна.")
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +42,11 @@ def get_current_admin(
|
|||||||
payload = security.decode_token(token, security.AUDIENCE_ADMIN)
|
payload = security.decode_token(token, security.AUDIENCE_ADMIN)
|
||||||
except jwt.PyJWTError:
|
except jwt.PyJWTError:
|
||||||
raise AuthError("Сессия администратора недействительна.")
|
raise AuthError("Сессия администратора недействительна.")
|
||||||
|
if security.is_session_revoked(payload):
|
||||||
|
raise AuthError("Сессия администратора недействительна.")
|
||||||
user = session.get(User, int(payload["sub"]))
|
user = session.get(User, int(payload["sub"]))
|
||||||
if user is None or user.role != "admin" or not user.is_active:
|
if user is None or user.role != "admin" or not user.is_active:
|
||||||
raise ForbiddenError("Доступ только для администратора.")
|
raise ForbiddenError("Доступ только для администратора.")
|
||||||
|
if int(payload.get("ver", 0)) != int(user.token_version or 0):
|
||||||
|
raise AuthError("Сессия администратора недействительна.")
|
||||||
return user
|
return user
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def establish_session(
|
|||||||
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
|
"""Открыть сессию уже найденному/созданному пользователю (cookie + аудит)."""
|
||||||
if not user.is_active:
|
if not user.is_active:
|
||||||
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
|
raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED")
|
||||||
security.set_user_session(response, user.id, provider) # type: ignore[arg-type]
|
security.set_user_session(response, user.id, provider, user.token_version) # type: ignore[arg-type]
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
actor_id=user.id,
|
actor_id=user.id,
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Вход игрока по логину (нику) и паролю.
|
||||||
|
|
||||||
|
Прод-модуль: основной способ входа во всех окружениях. НЕ путать с dev-stub (вход по нику
|
||||||
|
без пароля) — тот живёт в dev_stub.py и в прод-образ не попадает. Сюда dev-код не импортировать.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.core.errors import (
|
||||||
|
InvalidCredentialsError,
|
||||||
|
ValidationError,
|
||||||
|
WrongCurrentPasswordError,
|
||||||
|
)
|
||||||
|
from app.core.ratelimit import login_throttle
|
||||||
|
from app.core.security import hash_password, verify_password
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
PASSWORD_MIN_CHARS = 8
|
||||||
|
# bcrypt учитывает только первые 72 байта, а bcrypt 5 на более длинном пароле бросает
|
||||||
|
# ValueError — ограничиваем явно, с понятным сообщением.
|
||||||
|
_BCRYPT_MAX_BYTES = 72
|
||||||
|
|
||||||
|
# Лимиты неудач за окно LoginThrottle (15 минут): на пару «IP + логин» — против перебора
|
||||||
|
# одного аккаунта; на IP — против перебора по многим логинам с одного адреса.
|
||||||
|
_PAIR_LIMIT = 5
|
||||||
|
_IP_LIMIT = 20
|
||||||
|
# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно
|
||||||
|
# упирается в этот предел. Щедрее пары, чтобы поток ошибок с разных адресов не запирал
|
||||||
|
# вход настоящему владельцу (лимит на аккаунт — вектор lockout-DoS, потому не строгий);
|
||||||
|
# успешный вход его сбрасывает.
|
||||||
|
_ACCOUNT_LIMIT = 50
|
||||||
|
# Неверный текущий пароль при смене — на аккаунт.
|
||||||
|
_CURRENT_PASSWORD_LIMIT = 5
|
||||||
|
# Регистраций с одного IP за окно — против спама аккаунтов (#62).
|
||||||
|
_REGISTER_IP_LIMIT = 10
|
||||||
|
|
||||||
|
|
||||||
|
def validate_new_password(password: str) -> None:
|
||||||
|
if len(password) < PASSWORD_MIN_CHARS:
|
||||||
|
raise ValidationError(f"Пароль: не короче {PASSWORD_MIN_CHARS} символов.")
|
||||||
|
if not password.strip():
|
||||||
|
raise ValidationError("Пароль не может состоять из одних пробелов.")
|
||||||
|
if len(password.encode("utf-8")) > _BCRYPT_MAX_BYTES:
|
||||||
|
raise ValidationError(
|
||||||
|
"Пароль слишком длинный: до 72 байт (72 латинских или 36 русских букв)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def _dummy_hash() -> str:
|
||||||
|
return hash_password("dummy-password-for-timing")
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_player(session: Session, nickname: str, password: str) -> User:
|
||||||
|
"""Игрок по нику и паролю. Админ так не входит: у него отдельный вход и cookie.
|
||||||
|
|
||||||
|
Неизвестный логин и аккаунт без пароля сверяются с фиктивным хешем: ответ занимает
|
||||||
|
столько же, сколько неверный пароль, и по времени нельзя узнать, есть ли такой логин."""
|
||||||
|
user = session.exec(
|
||||||
|
select(User).where(User.nickname == nickname, User.role == "player")
|
||||||
|
).first()
|
||||||
|
stored = user.password_hash if user is not None else None
|
||||||
|
valid = verify_password(password, stored or _dummy_hash())
|
||||||
|
if user is None or not stored or not valid:
|
||||||
|
raise InvalidCredentialsError()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def login_player(session: Session, nickname: str, password: str, ip: str | None) -> User:
|
||||||
|
"""authenticate_player под защитой от перебора. Сессию открывает вызывающий."""
|
||||||
|
nickname = (nickname or "").strip()
|
||||||
|
ip = ip or "unknown"
|
||||||
|
pair_key = f"login:{ip}:{nickname.casefold()}"
|
||||||
|
account_key = f"login-user:{nickname.casefold()}"
|
||||||
|
limits = {pair_key: _PAIR_LIMIT, f"login-ip:{ip}": _IP_LIMIT, account_key: _ACCOUNT_LIMIT}
|
||||||
|
login_throttle.check(limits)
|
||||||
|
try:
|
||||||
|
user = authenticate_player(session, nickname, password)
|
||||||
|
except InvalidCredentialsError:
|
||||||
|
login_throttle.fail(limits)
|
||||||
|
raise
|
||||||
|
# Успех снимает счётчики этого аккаунта (пара IP+логин и лимит на аккаунт); лимит по IP
|
||||||
|
# оставляем — он общий для всех логинов с адреса.
|
||||||
|
login_throttle.reset(pair_key)
|
||||||
|
login_throttle.reset(account_key)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def throttle_register(ip: str | None) -> None:
|
||||||
|
"""Ограничивает частоту регистраций с одного IP (спам аккаунтов, #62).
|
||||||
|
|
||||||
|
Считаем каждую попытку (и успешную, и нет), поэтому массовое создание аккаунтов
|
||||||
|
с уникальными никами упирается в предел так же, как повторы по занятому нику.
|
||||||
|
Enumeration ников через 409 NICKNAME_TAKEN не закрываем: ники и так публичны в топе."""
|
||||||
|
ip = ip or "unknown"
|
||||||
|
limits = {f"register-ip:{ip}": _REGISTER_IP_LIMIT}
|
||||||
|
login_throttle.check(limits)
|
||||||
|
login_throttle.fail(limits)
|
||||||
|
|
||||||
|
|
||||||
|
def check_current_password(user: User, current_password: str | None) -> None:
|
||||||
|
"""Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя:
|
||||||
|
иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем."""
|
||||||
|
key = f"current-password:{user.id}"
|
||||||
|
limits = {key: _CURRENT_PASSWORD_LIMIT}
|
||||||
|
login_throttle.check(limits)
|
||||||
|
if not current_password or not verify_password(current_password, user.password_hash or ""):
|
||||||
|
login_throttle.fail(limits)
|
||||||
|
raise WrongCurrentPasswordError()
|
||||||
|
login_throttle.reset(key)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Доступные методы входа по окружению.
|
"""Доступные методы входа по окружению.
|
||||||
|
|
||||||
Telegram — всегда; stub (вход по нику) — только в development (test/prod = только TG).
|
Логин/пароль и Telegram — всегда; stub (вход по нику без пароля) — только в development.
|
||||||
Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода.
|
Здесь НЕТ импорта dev-провайдера, чтобы прод-образ не зависел от dev-кода.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -9,7 +9,7 @@ from app.core.config import settings
|
|||||||
|
|
||||||
|
|
||||||
def enabled_methods() -> list[str]:
|
def enabled_methods() -> list[str]:
|
||||||
methods = ["telegram"]
|
methods = ["password", "telegram"]
|
||||||
if settings.is_development:
|
if settings.is_development:
|
||||||
methods.append("stub")
|
methods.append("stub")
|
||||||
return methods
|
return methods
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
|
# Единый .env лежит в КОРНЕ репозитория (рядом с .env.example) — читается одинаково
|
||||||
@@ -11,6 +12,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
||||||
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
||||||
|
|
||||||
|
# Небезопасные значения по умолчанию (годятся только для dev). В production приложение
|
||||||
|
# с ними не стартует — см. валидатор _forbid_default_secrets_in_prod (#59).
|
||||||
|
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
|
||||||
|
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
|
||||||
|
_MIN_SECRET_KEY_LENGTH = 32
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
@@ -50,7 +57,7 @@ class Settings(BaseSettings):
|
|||||||
prod_achievements_dir: str = "/data/achievements"
|
prod_achievements_dir: str = "/data/achievements"
|
||||||
|
|
||||||
# JWT / cookie
|
# JWT / cookie
|
||||||
secret_key: str = "change-me-dev-secret-not-for-production"
|
secret_key: str = _DEFAULT_SECRET_KEY
|
||||||
jwt_algorithm: str = "HS256"
|
jwt_algorithm: str = "HS256"
|
||||||
jwt_user_ttl_minutes: int = 60 * 24 * 7
|
jwt_user_ttl_minutes: int = 60 * 24 * 7
|
||||||
jwt_admin_ttl_minutes: int = 60 * 8
|
jwt_admin_ttl_minutes: int = 60 * 8
|
||||||
@@ -66,7 +73,7 @@ class Settings(BaseSettings):
|
|||||||
# Бутстрап администратора
|
# Бутстрап администратора
|
||||||
admin_bootstrap_enabled: bool = True
|
admin_bootstrap_enabled: bool = True
|
||||||
admin_username: str = "admin"
|
admin_username: str = "admin"
|
||||||
admin_password: str = "change-me-admin-password"
|
admin_password: str = _DEFAULT_ADMIN_PASSWORD
|
||||||
admin_nickname: str = "Администратор"
|
admin_nickname: str = "Администратор"
|
||||||
|
|
||||||
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
|
# CORS (для раздельного dev-режима фронта). Строка из env, через запятую —
|
||||||
@@ -115,6 +122,31 @@ class Settings(BaseSettings):
|
|||||||
def cookie_domain_value(self) -> str | None:
|
def cookie_domain_value(self) -> str | None:
|
||||||
return self.cookie_domain or None
|
return self.cookie_domain or None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _forbid_default_secrets_in_prod(self) -> "Settings":
|
||||||
|
"""Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59).
|
||||||
|
|
||||||
|
Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы
|
||||||
|
с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и
|
||||||
|
известным паролем администратора. В dev/test проверка не мешает — там дефолты норма."""
|
||||||
|
if self.app_env.lower() != "production":
|
||||||
|
return self
|
||||||
|
problems: list[str] = []
|
||||||
|
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH:
|
||||||
|
problems.append(
|
||||||
|
f"SECRET_KEY не задан, дефолтный или короче {_MIN_SECRET_KEY_LENGTH} символов"
|
||||||
|
)
|
||||||
|
if self.admin_bootstrap_enabled:
|
||||||
|
password = (self.admin_password or "").strip()
|
||||||
|
if not password or password == _DEFAULT_ADMIN_PASSWORD:
|
||||||
|
problems.append("ADMIN_PASSWORD не задан или дефолтный")
|
||||||
|
if problems:
|
||||||
|
raise ValueError(
|
||||||
|
"Небезопасная конфигурация production — задайте секреты в .env: "
|
||||||
|
+ "; ".join(problems)
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
def get_settings() -> Settings:
|
def get_settings() -> Settings:
|
||||||
|
|||||||
@@ -136,5 +136,41 @@ class InvalidCredentialsError(AuthError):
|
|||||||
super().__init__("Неверный логин или пароль.")
|
super().__init__("Неверный логин или пароль.")
|
||||||
|
|
||||||
|
|
||||||
|
class TooManyAttemptsError(AppError):
|
||||||
|
"""Слишком много неудачных попыток ввода пароля. retry_after — через сколько секунд
|
||||||
|
ближайшая попытка снова будет принята."""
|
||||||
|
|
||||||
|
status_code = 429
|
||||||
|
code = "TOO_MANY_ATTEMPTS"
|
||||||
|
|
||||||
|
def __init__(self, retry_after: int) -> None:
|
||||||
|
minutes = max(1, -(-retry_after // 60))
|
||||||
|
super().__init__(
|
||||||
|
f"Слишком много неудачных попыток. Повторите через {minutes} мин.",
|
||||||
|
details={"retry_after": retry_after},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WrongCurrentPasswordError(ForbiddenError):
|
||||||
|
code = "WRONG_CURRENT_PASSWORD"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("Текущий пароль неверен.")
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramAlreadyLinkedError(ConflictError):
|
||||||
|
code = "TELEGRAM_ALREADY_LINKED"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("К аккаунту уже привязан Telegram.")
|
||||||
|
|
||||||
|
|
||||||
|
class TelegramTakenError(ConflictError):
|
||||||
|
code = "TELEGRAM_TAKEN"
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__("Этот Telegram уже привязан к другому аккаунту.")
|
||||||
|
|
||||||
|
|
||||||
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
|
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
|
||||||
return exc.to_response()
|
return exc.to_response()
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Ограничение неудачных попыток ввода пароля (защита от перебора).
|
||||||
|
|
||||||
|
Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn
|
||||||
|
(`--workers 1`, см. entrypoint.sh/run.*). Перезапуск их обнуляет; для окна в 15 минут это
|
||||||
|
приемлемо. Ограничения устойчивости (#60):
|
||||||
|
* рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после
|
||||||
|
перезапуска, но окно короткое, а рестарты редки;
|
||||||
|
* при уходе от одного воркера лимит делится между процессами (каждый считает своё) —
|
||||||
|
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в CLAUDE.md
|
||||||
|
про SSE-шину), общий для всех воркеров.
|
||||||
|
Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт
|
||||||
|
(`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов
|
||||||
|
(#58) не снимала защиту полностью.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
from app.core.errors import TooManyAttemptsError
|
||||||
|
|
||||||
|
_WINDOW_SECONDS = 15 * 60
|
||||||
|
# Выше этого числа ключей при записи неудачи вычищаем протухшие, чтобы поток попыток
|
||||||
|
# с разных адресов не копил память бесконечно.
|
||||||
|
_PRUNE_ABOVE = 10_000
|
||||||
|
|
||||||
|
|
||||||
|
class LoginThrottle:
|
||||||
|
"""Скользящее окно неудач по произвольным ключам (IP, пара «IP + логин», id игрока).
|
||||||
|
|
||||||
|
Роуты синхронные и выполняются в пуле потоков, поэтому доступ под замком."""
|
||||||
|
|
||||||
|
def __init__(self, window_seconds: int = _WINDOW_SECONDS) -> None:
|
||||||
|
self.window = window_seconds
|
||||||
|
self._fails: dict[str, deque[float]] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def _recent(self, key: str, now: float) -> deque[float]:
|
||||||
|
attempts = self._fails.get(key)
|
||||||
|
if attempts is None:
|
||||||
|
return deque()
|
||||||
|
while attempts and now - attempts[0] >= self.window:
|
||||||
|
attempts.popleft()
|
||||||
|
if not attempts:
|
||||||
|
del self._fails[key]
|
||||||
|
return attempts
|
||||||
|
|
||||||
|
def check(self, limits: dict[str, int]) -> None:
|
||||||
|
"""Бросает TooManyAttemptsError, если хотя бы по одному ключу лимит исчерпан."""
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
for key, limit in limits.items():
|
||||||
|
attempts = self._recent(key, now)
|
||||||
|
if len(attempts) >= limit:
|
||||||
|
# Попытка снова примется, когда из окна выпадет неудача, после которой
|
||||||
|
# в нём остаётся limit-1 записей.
|
||||||
|
frees_at = attempts[len(attempts) - limit] + self.window
|
||||||
|
raise TooManyAttemptsError(retry_after=int(frees_at - now) + 1)
|
||||||
|
|
||||||
|
def fail(self, keys: dict[str, int]) -> None:
|
||||||
|
now = time.monotonic()
|
||||||
|
with self._lock:
|
||||||
|
if len(self._fails) > _PRUNE_ABOVE:
|
||||||
|
for key in list(self._fails):
|
||||||
|
self._recent(key, now)
|
||||||
|
for key in keys:
|
||||||
|
self._fails.setdefault(key, deque()).append(now)
|
||||||
|
|
||||||
|
def reset(self, key: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._fails.pop(key, None)
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._fails.clear()
|
||||||
|
|
||||||
|
|
||||||
|
login_throttle = LoginThrottle()
|
||||||
@@ -9,6 +9,7 @@ import jwt
|
|||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.core.token_revocation import revoked_tokens
|
||||||
|
|
||||||
USER_COOKIE = "fs_session"
|
USER_COOKIE = "fs_session"
|
||||||
ADMIN_COOKIE = "fs_admin"
|
ADMIN_COOKIE = "fs_admin"
|
||||||
@@ -37,7 +38,13 @@ def verify_password(password: str, password_hash: str) -> bool:
|
|||||||
|
|
||||||
# ─── JWT ─────────────────────────────────────────────────────────────────────
|
# ─── JWT ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def create_token(subject: str | int, audience: str, ttl_minutes: int, provider: str = "") -> str:
|
def create_token(
|
||||||
|
subject: str | int,
|
||||||
|
audience: str,
|
||||||
|
ttl_minutes: int,
|
||||||
|
provider: str = "",
|
||||||
|
token_version: int = 0,
|
||||||
|
) -> str:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
payload = {
|
payload = {
|
||||||
"sub": str(subject),
|
"sub": str(subject),
|
||||||
@@ -46,6 +53,9 @@ def create_token(subject: str | int, audience: str, ttl_minutes: int, provider:
|
|||||||
"exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()),
|
"exp": int((now + timedelta(minutes=ttl_minutes)).timestamp()),
|
||||||
"jti": secrets.token_hex(8),
|
"jti": secrets.token_hex(8),
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
|
# Версия сессий владельца: при её росте (смена/сброс пароля) старые токены
|
||||||
|
# с меньшим `ver` отклоняются в auth/deps — отзыв всех прежних сессий (#57).
|
||||||
|
"ver": token_version,
|
||||||
}
|
}
|
||||||
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
return jwt.encode(payload, settings.secret_key, algorithm=settings.jwt_algorithm)
|
||||||
|
|
||||||
@@ -95,9 +105,9 @@ def fresh_csrf_set_cookie() -> tuple[bytes, bytes]:
|
|||||||
return next((k, v) for k, v in carrier.raw_headers if k == b"set-cookie")
|
return next((k, v) for k, v in carrier.raw_headers if k == b"set-cookie")
|
||||||
|
|
||||||
|
|
||||||
def set_user_session(response: Response, user_id: int, provider: str) -> None:
|
def set_user_session(response: Response, user_id: int, provider: str, token_version: int = 0) -> None:
|
||||||
ttl = settings.jwt_user_ttl_minutes
|
ttl = settings.jwt_user_ttl_minutes
|
||||||
token = create_token(user_id, AUDIENCE_USER, ttl, provider)
|
token = create_token(user_id, AUDIENCE_USER, ttl, provider, token_version)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=USER_COOKIE,
|
key=USER_COOKIE,
|
||||||
value=token,
|
value=token,
|
||||||
@@ -111,9 +121,9 @@ def set_user_session(response: Response, user_id: int, provider: str) -> None:
|
|||||||
_set_csrf_cookie(response)
|
_set_csrf_cookie(response)
|
||||||
|
|
||||||
|
|
||||||
def set_admin_session(response: Response, admin_id: int) -> None:
|
def set_admin_session(response: Response, admin_id: int, token_version: int = 0) -> None:
|
||||||
ttl = settings.jwt_admin_ttl_minutes
|
ttl = settings.jwt_admin_ttl_minutes
|
||||||
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local")
|
token = create_token(admin_id, AUDIENCE_ADMIN, ttl, "local", token_version)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key=ADMIN_COOKIE,
|
key=ADMIN_COOKIE,
|
||||||
value=token,
|
value=token,
|
||||||
@@ -135,6 +145,33 @@ def clear_admin_session(response: Response) -> None:
|
|||||||
response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value)
|
response.delete_cookie(ADMIN_COOKIE, path=_ADMIN_PATH, domain=settings.cookie_domain_value)
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_session_token(request: Request, cookie_name: str, audience: str) -> None:
|
||||||
|
"""Отзывает предъявленный в cookie токен (по `jti`) до его `exp` — точечный logout.
|
||||||
|
|
||||||
|
Убивает именно этот токен (украденный/оставленный), не трогая другие устройства.
|
||||||
|
Некорректный/просроченный токен отзывать нечего — молча выходим."""
|
||||||
|
token = request.cookies.get(cookie_name)
|
||||||
|
if not token:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.secret_key,
|
||||||
|
algorithms=[settings.jwt_algorithm],
|
||||||
|
audience=audience,
|
||||||
|
)
|
||||||
|
except jwt.PyJWTError:
|
||||||
|
return
|
||||||
|
exp = payload.get("exp")
|
||||||
|
if exp is not None:
|
||||||
|
revoked_tokens.revoke(payload.get("jti"), float(exp))
|
||||||
|
|
||||||
|
|
||||||
|
def is_session_revoked(payload: dict) -> bool:
|
||||||
|
"""Отозван ли этот токен точечно (через logout)."""
|
||||||
|
return revoked_tokens.is_revoked(payload.get("jti"))
|
||||||
|
|
||||||
|
|
||||||
def client_ip(request: Request) -> str | None:
|
def client_ip(request: Request) -> str | None:
|
||||||
"""IP клиента для журнала аудита.
|
"""IP клиента для журнала аудита.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
"""Точечный отзыв отдельных JWT по `jti` — для logout (выход именно этого токена).
|
||||||
|
|
||||||
|
Список живёт в памяти процесса, как throttle и SSE-шина: рассчитан на один воркер uvicorn.
|
||||||
|
Рестарт очищает список — это приемлемо: записи и так живут лишь до `exp` токена, а на новый
|
||||||
|
процесс приходят уже свежие cookie. Для «выйти со всех устройств» и отзыва при смене пароля
|
||||||
|
используется `token_version` у пользователя (см. models.User, auth/deps), а не этот список.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
# Выше этого числа записей при отзыве вычищаем протухшие, чтобы поток logout'ов не копил память.
|
||||||
|
_PRUNE_ABOVE = 10_000
|
||||||
|
|
||||||
|
|
||||||
|
class RevokedTokens:
|
||||||
|
"""Множество отозванных `jti` с временем истечения (unix-время, как `exp` в JWT)."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._revoked: dict[str, float] = {}
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def revoke(self, jti: str | None, expires_at: float) -> None:
|
||||||
|
if not jti:
|
||||||
|
return
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
if len(self._revoked) > _PRUNE_ABOVE:
|
||||||
|
for key, exp in list(self._revoked.items()):
|
||||||
|
if exp <= now:
|
||||||
|
del self._revoked[key]
|
||||||
|
self._revoked[jti] = expires_at
|
||||||
|
|
||||||
|
def is_revoked(self, jti: str | None) -> bool:
|
||||||
|
if not jti:
|
||||||
|
return False
|
||||||
|
now = time.time()
|
||||||
|
with self._lock:
|
||||||
|
exp = self._revoked.get(jti)
|
||||||
|
if exp is None:
|
||||||
|
return False
|
||||||
|
if exp <= now:
|
||||||
|
del self._revoked[jti] # протухла — заодно вычищаем
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._revoked.clear()
|
||||||
|
|
||||||
|
|
||||||
|
revoked_tokens = RevokedTokens()
|
||||||
+12
-4
@@ -151,12 +151,16 @@ async def _lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
|
# Схему API (openapi.json + Swagger/ReDoc) отдаём только в dev/test: она нужна для
|
||||||
|
# `npm run gen:api` (генерация типов фронта) и удобной отладки. В production закрываем —
|
||||||
|
# незачем облегчать разведку поверхности API анонимам (#61).
|
||||||
|
docs_enabled = not settings.is_production
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Forbidden Stars API",
|
title="Forbidden Stars API",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
openapi_url="/api/openapi.json",
|
openapi_url="/api/openapi.json" if docs_enabled else None,
|
||||||
docs_url="/api/docs",
|
docs_url="/api/docs" if docs_enabled else None,
|
||||||
redoc_url="/api/redoc",
|
redoc_url="/api/redoc" if docs_enabled else None,
|
||||||
lifespan=_lifespan,
|
lifespan=_lifespan,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -177,13 +181,17 @@ def create_app() -> FastAPI:
|
|||||||
|
|
||||||
@app.exception_handler(RequestValidationError)
|
@app.exception_handler(RequestValidationError)
|
||||||
async def _validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
async def _validation_handler(_request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||||
|
# Только type/loc/msg. В input лежит тело запроса: эхо паролей в ответ, а для тела
|
||||||
|
# не в JSON (text/plain от HTML-формы) — сырые bytes, которые JSON не сериализует,
|
||||||
|
# и ответ падал в 500. В ctx бывают объекты исключений — та же проблема.
|
||||||
|
details = [{"type": e["type"], "loc": e["loc"], "msg": e["msg"]} for e in exc.errors()]
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=422,
|
status_code=422,
|
||||||
content={
|
content={
|
||||||
"error": {
|
"error": {
|
||||||
"code": "VALIDATION_ERROR",
|
"code": "VALIDATION_ERROR",
|
||||||
"message": "Ошибка валидации запроса.",
|
"message": "Ошибка валидации запроса.",
|
||||||
"details": exc.errors(),
|
"details": details,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -93,6 +93,13 @@ class User(SQLModel, table=True):
|
|||||||
sa_column=Column(String(16), nullable=False, server_default="stub"),
|
sa_column=Column(String(16), nullable=False, server_default="stub"),
|
||||||
)
|
)
|
||||||
password_hash: str | None = Field(sa_column=Column(String(255), nullable=True))
|
password_hash: str | None = Field(sa_column=Column(String(255), nullable=True))
|
||||||
|
# Версия сессий: инкремент отзывает все ранее выданные JWT этого пользователя
|
||||||
|
# (claim `ver` в токене сверяется с этим полем в auth/deps). Растёт при смене пароля
|
||||||
|
# и сбросе пароля админом — компрометация или утечка токена так прекращается (#57).
|
||||||
|
token_version: int = Field(
|
||||||
|
default=0,
|
||||||
|
sa_column=Column(Integer, nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
active_group_id: int | None = Field(
|
active_group_id: int | None = Field(
|
||||||
sa_column=Column(
|
sa_column=Column(
|
||||||
Integer,
|
Integer,
|
||||||
|
|||||||
@@ -5,10 +5,11 @@ from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFi
|
|||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
|
from app.auth.admin_login import login_admin
|
||||||
from app.auth.deps import get_current_admin
|
from app.auth.deps import get_current_admin
|
||||||
from app.core import security
|
from app.core import security
|
||||||
from app.core.security import client_ip
|
from app.core.security import client_ip
|
||||||
from app.core.errors import NotFoundError
|
from app.core.errors import InvalidCredentialsError, NotFoundError
|
||||||
from app.core.timeutil import iso_utc
|
from app.core.timeutil import iso_utc
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models import User
|
from app.models import User
|
||||||
@@ -40,8 +41,23 @@ def admin_login(
|
|||||||
response: Response,
|
response: Response,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
) -> s.AdminMe:
|
) -> s.AdminMe:
|
||||||
admin = admin_service.authenticate_admin(session, body.username, body.password)
|
try:
|
||||||
security.set_admin_session(response, admin.id) # type: ignore[arg-type]
|
admin = login_admin(session, body.username, body.password, client_ip(request))
|
||||||
|
except InvalidCredentialsError:
|
||||||
|
# Неудачную попытку фиксируем в аудите (перебор пароля админа — прямой путь к
|
||||||
|
# полному контролю). Серию таких попыток ограничивает throttle в login_admin (#56).
|
||||||
|
audit_service.record(
|
||||||
|
session,
|
||||||
|
actor_id=None,
|
||||||
|
action="login_failed",
|
||||||
|
entity_type="admin",
|
||||||
|
payload={"username": (body.username or "")[:64]},
|
||||||
|
ip=client_ip(request),
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
raise
|
||||||
|
security.set_admin_session(response, admin.id, admin.token_version) # type: ignore[arg-type]
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
actor_id=admin.id,
|
actor_id=admin.id,
|
||||||
@@ -56,7 +72,8 @@ def admin_login(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/auth/logout", response_model=s.OkResponse)
|
@router.post("/auth/logout", response_model=s.OkResponse)
|
||||||
def admin_logout(response: Response) -> s.OkResponse:
|
def admin_logout(request: Request, response: Response) -> s.OkResponse:
|
||||||
|
security.revoke_session_token(request, security.ADMIN_COOKIE, security.AUDIENCE_ADMIN)
|
||||||
security.clear_admin_session(response)
|
security.clear_admin_session(response)
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|
||||||
@@ -68,24 +85,26 @@ def admin_me(admin: User = Depends(get_current_admin)) -> s.AdminMe:
|
|||||||
|
|
||||||
# ─── Пользователи ────────────────────────────────────────────────────────────
|
# ─── Пользователи ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _admin_user_read(u: User) -> s.AdminUserRead:
|
||||||
|
return s.AdminUserRead(
|
||||||
|
id=u.id, # type: ignore[arg-type]
|
||||||
|
nickname=u.nickname,
|
||||||
|
role=u.role,
|
||||||
|
is_active=u.is_active,
|
||||||
|
auth_provider=u.auth_provider,
|
||||||
|
telegram_id=u.telegram_id,
|
||||||
|
created_at=iso_utc(u.created_at),
|
||||||
|
has_password=u.password_hash is not None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/users", response_model=list[s.AdminUserRead])
|
@router.get("/users", response_model=list[s.AdminUserRead])
|
||||||
def list_users(
|
def list_users(
|
||||||
query: str | None = Query(None),
|
query: str | None = Query(None),
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
_admin: User = Depends(get_current_admin),
|
_admin: User = Depends(get_current_admin),
|
||||||
) -> list[s.AdminUserRead]:
|
) -> list[s.AdminUserRead]:
|
||||||
return [
|
return [_admin_user_read(u) for u in admin_service.list_users(session, query)]
|
||||||
s.AdminUserRead(
|
|
||||||
id=u.id, # type: ignore[arg-type]
|
|
||||||
nickname=u.nickname,
|
|
||||||
role=u.role,
|
|
||||||
is_active=u.is_active,
|
|
||||||
auth_provider=u.auth_provider,
|
|
||||||
telegram_id=u.telegram_id,
|
|
||||||
created_at=iso_utc(u.created_at),
|
|
||||||
)
|
|
||||||
for u in admin_service.list_users(session, query)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/users/{user_id}", response_model=s.AdminUserRead)
|
@router.patch("/users/{user_id}", response_model=s.AdminUserRead)
|
||||||
@@ -107,15 +126,30 @@ def update_user(
|
|||||||
ip=client_ip(request),
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
return s.AdminUserRead(
|
return _admin_user_read(u)
|
||||||
id=u.id, # type: ignore[arg-type]
|
|
||||||
nickname=u.nickname,
|
|
||||||
role=u.role,
|
@router.put("/users/{user_id}/password", response_model=s.AdminUserRead)
|
||||||
is_active=u.is_active,
|
def set_user_password(
|
||||||
auth_provider=u.auth_provider,
|
user_id: int,
|
||||||
telegram_id=u.telegram_id,
|
body: s.AdminPasswordSet,
|
||||||
created_at=iso_utc(u.created_at),
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
admin: User = Depends(get_current_admin),
|
||||||
|
) -> s.AdminUserRead:
|
||||||
|
"""Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется."""
|
||||||
|
u = admin_service.set_player_password(session, user_id, body.new_password)
|
||||||
|
audit_service.record(
|
||||||
|
session,
|
||||||
|
actor_id=admin.id,
|
||||||
|
action="update",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user_id,
|
||||||
|
payload={"password": "set_by_admin"},
|
||||||
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
|
session.commit()
|
||||||
|
return _admin_user_read(u)
|
||||||
|
|
||||||
|
|
||||||
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
|
# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"""Постоянный роутер аутентификации: конфиг, Telegram-вход, выход.
|
"""Постоянный роутер аутентификации: конфиг, вход по паролю, Telegram-вход, выход.
|
||||||
|
|
||||||
Stub-вход (по нику) физически вынесен в routers/dev_auth.py и доступен только в dev.
|
Stub-вход (по нику без пароля) физически вынесен в routers/dev_auth.py и доступен только в dev.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -8,15 +8,17 @@ from fastapi import APIRouter, Depends, Request, Response
|
|||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.login import establish_session
|
from app.auth.login import establish_session
|
||||||
|
from app.auth.password import login_player, throttle_register
|
||||||
from app.auth.registry import enabled_methods
|
from app.auth.registry import enabled_methods
|
||||||
from app.auth.telegram import TelegramProvider
|
from app.auth.telegram import TelegramProvider
|
||||||
from app.core import security
|
from app.core import security
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.errors import TelegramNicknameRequiredError
|
from app.core.errors import TelegramNicknameRequiredError
|
||||||
|
from app.core.security import client_ip
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.routers.users import build_me
|
from app.routers.users import build_me
|
||||||
from app.schemas import api as s
|
from app.schemas import api as s
|
||||||
from app.services import user_service
|
from app.services import audit_service, user_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
@@ -29,6 +31,43 @@ def auth_config() -> s.AuthConfig:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=s.MeRead)
|
||||||
|
def password_register(
|
||||||
|
body: s.PasswordRegister,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> s.MeRead:
|
||||||
|
"""Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле."""
|
||||||
|
throttle_register(client_ip(request)) # против спама аккаунтов с одного IP (#62)
|
||||||
|
user = user_service.register_local(session, body.nickname, body.password)
|
||||||
|
audit_service.record(
|
||||||
|
session,
|
||||||
|
actor_id=user.id,
|
||||||
|
action="create",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
payload={"provider": "local"},
|
||||||
|
ip=client_ip(request),
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
)
|
||||||
|
establish_session(session, response, request, user, "local")
|
||||||
|
return build_me(session, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=s.MeRead)
|
||||||
|
def password_login(
|
||||||
|
body: s.PasswordLogin,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
) -> s.MeRead:
|
||||||
|
"""Вход игрока по нику и паролю. Неверная пара — 401, перебор — 429."""
|
||||||
|
user = login_player(session, body.nickname, body.password, client_ip(request))
|
||||||
|
establish_session(session, response, request, user, "local")
|
||||||
|
return build_me(session, user)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/telegram", response_model=s.MeRead)
|
@router.post("/telegram", response_model=s.MeRead)
|
||||||
def telegram_login(
|
def telegram_login(
|
||||||
body: s.TelegramAuthPayload,
|
body: s.TelegramAuthPayload,
|
||||||
@@ -78,6 +117,9 @@ def telegram_register(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/logout", response_model=s.OkResponse)
|
@router.post("/logout", response_model=s.OkResponse)
|
||||||
def logout(response: Response) -> s.OkResponse:
|
def logout(request: Request, response: Response) -> s.OkResponse:
|
||||||
|
# Отзываем именно предъявленный токен (по jti) до его exp: украденная/оставленная
|
||||||
|
# cookie перестаёт работать сразу, а не живёт до конца TTL (#57).
|
||||||
|
security.revoke_session_token(request, security.USER_COOKIE, security.AUDIENCE_USER)
|
||||||
security.clear_user_session(response)
|
security.clear_user_session(response)
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"""Роутер текущего пользователя."""
|
"""Роутер текущего пользователя."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
|
from fastapi import APIRouter, Depends, File, Query, Request, Response, UploadFile
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse
|
||||||
from sqlmodel import Session
|
from sqlmodel import Session
|
||||||
|
|
||||||
from app.auth.deps import get_current_user
|
from app.auth.deps import get_current_user
|
||||||
from app.core.security import client_ip
|
from app.auth.password import check_current_password
|
||||||
|
from app.auth.telegram import TelegramProvider
|
||||||
|
from app.core.security import client_ip, set_user_session
|
||||||
from app.core.errors import NotFoundError
|
from app.core.errors import NotFoundError
|
||||||
from app.db.session import get_session
|
from app.db.session import get_session
|
||||||
from app.models import User
|
from app.models import User
|
||||||
@@ -36,6 +38,7 @@ def build_me(session: Session, user: User) -> s.MeRead:
|
|||||||
history_mode=user.history_mode,
|
history_mode=user.history_mode,
|
||||||
history_detail=user.history_detail,
|
history_detail=user.history_detail,
|
||||||
groups=groups,
|
groups=groups,
|
||||||
|
has_password=user.password_hash is not None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -115,6 +118,60 @@ def update_my_profile(
|
|||||||
return build_me(session, user)
|
return build_me(session, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/me/password", response_model=s.MeRead)
|
||||||
|
def change_my_password(
|
||||||
|
body: s.PasswordChange,
|
||||||
|
request: Request,
|
||||||
|
response: Response,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> s.MeRead:
|
||||||
|
"""Задать пароль (первый раз — без текущего) или сменить его (нужен текущий)."""
|
||||||
|
had_password = user.password_hash is not None
|
||||||
|
if had_password:
|
||||||
|
check_current_password(user, body.current_password)
|
||||||
|
user_service.set_password(session, user, body.new_password)
|
||||||
|
# set_password инкрементит token_version → все ранее выданные токены отозваны (#57).
|
||||||
|
# Перевыдаём cookie этому устройству со свежим ver, чтобы разлогинить только остальные.
|
||||||
|
set_user_session(response, user.id, user.auth_provider, user.token_version)
|
||||||
|
audit_service.record(
|
||||||
|
session,
|
||||||
|
actor_id=user.id,
|
||||||
|
action="update",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
payload={"password": "changed" if had_password else "set"},
|
||||||
|
ip=client_ip(request),
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return build_me(session, user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/telegram", response_model=s.MeRead)
|
||||||
|
def link_my_telegram(
|
||||||
|
body: s.TelegramAuthPayload,
|
||||||
|
request: Request,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> s.MeRead:
|
||||||
|
"""Привязать Telegram к аккаунту. Подпись виджета проверяется так же, как при входе."""
|
||||||
|
identity = TelegramProvider().authenticate(body.model_dump())
|
||||||
|
user_service.link_telegram(session, user, identity)
|
||||||
|
audit_service.record(
|
||||||
|
session,
|
||||||
|
actor_id=user.id,
|
||||||
|
action="update",
|
||||||
|
entity_type="user",
|
||||||
|
entity_id=user.id,
|
||||||
|
payload={"telegram": "linked"},
|
||||||
|
ip=client_ip(request),
|
||||||
|
user_agent=request.headers.get("user-agent"),
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
return build_me(session, user)
|
||||||
|
|
||||||
|
|
||||||
@router.put("/me/avatar", response_model=s.MeRead)
|
@router.put("/me/avatar", response_model=s.MeRead)
|
||||||
def upload_my_avatar(
|
def upload_my_avatar(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
|
|||||||
@@ -12,11 +12,33 @@ WinReason = Literal["objectives", "worlds", "plastic", "resources"]
|
|||||||
# ─── Auth ────────────────────────────────────────────────────────────────────
|
# ─── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class AuthConfig(BaseModel):
|
class AuthConfig(BaseModel):
|
||||||
# Доступные методы входа: ["telegram"] в проде, ["telegram","stub"] в деве.
|
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
|
||||||
methods: list[str] = []
|
methods: list[str] = []
|
||||||
telegram_bot_username: str | None = None
|
telegram_bot_username: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
|
||||||
|
# Точное правило для нового пароля (8 символов .. 72 байта) — в app/auth/password.py.
|
||||||
|
_PASSWORD_MAX_CHARS = 128
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordLogin(BaseModel):
|
||||||
|
# Логин — это ник игрока.
|
||||||
|
nickname: str
|
||||||
|
password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordRegister(BaseModel):
|
||||||
|
nickname: str
|
||||||
|
password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordChange(BaseModel):
|
||||||
|
# current_password нужен, только если пароль уже задан; первая установка — без него.
|
||||||
|
current_password: str | None = Field(default=None, max_length=_PASSWORD_MAX_CHARS)
|
||||||
|
new_password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
||||||
|
|
||||||
|
|
||||||
class TelegramAuthPayload(BaseModel):
|
class TelegramAuthPayload(BaseModel):
|
||||||
# Полезная нагрузка Telegram Login Widget (проверяется по HMAC).
|
# Полезная нагрузка Telegram Login Widget (проверяется по HMAC).
|
||||||
model_config = ConfigDict(extra="allow")
|
model_config = ConfigDict(extra="allow")
|
||||||
@@ -99,6 +121,9 @@ class UserRead(BaseModel):
|
|||||||
|
|
||||||
class MeRead(UserRead):
|
class MeRead(UserRead):
|
||||||
groups: list[GroupBrief] = []
|
groups: list[GroupBrief] = []
|
||||||
|
# False — пароль ещё не задан (аккаунт из Telegram или до появления паролей):
|
||||||
|
# фронт не пускает дальше окна установки пароля.
|
||||||
|
has_password: bool = False
|
||||||
|
|
||||||
|
|
||||||
class NicknameUpdate(BaseModel):
|
class NicknameUpdate(BaseModel):
|
||||||
@@ -493,6 +518,7 @@ class AdminUserRead(BaseModel):
|
|||||||
auth_provider: str
|
auth_provider: str
|
||||||
telegram_id: int | None = None
|
telegram_id: int | None = None
|
||||||
created_at: str
|
created_at: str
|
||||||
|
has_password: bool = False
|
||||||
|
|
||||||
|
|
||||||
class AdminUserUpdate(BaseModel):
|
class AdminUserUpdate(BaseModel):
|
||||||
@@ -500,6 +526,11 @@ class AdminUserUpdate(BaseModel):
|
|||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminPasswordSet(BaseModel):
|
||||||
|
# Новый пароль игроку от админа — способ восстановить забытый пароль.
|
||||||
|
new_password: str = Field(max_length=_PASSWORD_MAX_CHARS)
|
||||||
|
|
||||||
|
|
||||||
class AdminGroupRead(BaseModel):
|
class AdminGroupRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
|
|||||||
@@ -62,6 +62,17 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
|
|||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def set_player_password(session: Session, user_id: int, new_password: str) -> User:
|
||||||
|
"""Новый пароль игроку (восстановление забытого). Пароль админа так не меняется —
|
||||||
|
он задаётся ADMIN_PASSWORD в .env."""
|
||||||
|
user = session.get(User, user_id)
|
||||||
|
if user is None:
|
||||||
|
raise NotFoundError("Пользователь не найден.")
|
||||||
|
if user.role != "player":
|
||||||
|
raise ValidationError("Пароль администратора здесь не меняется.")
|
||||||
|
return user_service.set_password(session, user, new_password)
|
||||||
|
|
||||||
|
|
||||||
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
|
# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно:
|
||||||
# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа).
|
# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа).
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,17 @@ from pathlib import Path
|
|||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.auth.password import validate_new_password
|
||||||
from app.auth.provider import ExternalIdentity
|
from app.auth.provider import ExternalIdentity
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
|
from app.core.errors import (
|
||||||
|
NicknameTakenError,
|
||||||
|
NotFoundError,
|
||||||
|
TelegramAlreadyLinkedError,
|
||||||
|
TelegramTakenError,
|
||||||
|
ValidationError,
|
||||||
|
)
|
||||||
|
from app.core.security import hash_password
|
||||||
from app.core.timeutil import utcnow
|
from app.core.timeutil import utcnow
|
||||||
from app.models import AuthIdentity, Faction, User
|
from app.models import AuthIdentity, Faction, User
|
||||||
|
|
||||||
@@ -132,6 +140,70 @@ def register_from_identity(
|
|||||||
return _create_from_identity(session, identity, nickname)
|
return _create_from_identity(session, identity, nickname)
|
||||||
|
|
||||||
|
|
||||||
|
def register_local(session: Session, nickname: str, password: str) -> User:
|
||||||
|
"""Регистрация по логину и паролю. Логин — это ник; Telegram можно привязать позже."""
|
||||||
|
nickname = (nickname or "").strip()
|
||||||
|
if not nickname_format_ok(nickname):
|
||||||
|
raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_")
|
||||||
|
if not nickname_available(session, nickname):
|
||||||
|
raise NicknameTakenError()
|
||||||
|
validate_new_password(password)
|
||||||
|
user = User(
|
||||||
|
nickname=nickname,
|
||||||
|
role="player",
|
||||||
|
auth_provider="local",
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def set_password(session: Session, user: User, new_password: str) -> User:
|
||||||
|
"""Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет).
|
||||||
|
|
||||||
|
Инкремент token_version отзывает все ранее выданные токены (#57): при смене пароля
|
||||||
|
игроком — все прочие сессии, при сбросе админом — все сессии игрока (в т.ч. злоумышленника).
|
||||||
|
Своё устройство остаётся в сессии, только если вызывающий перевыдаст cookie со свежим ver."""
|
||||||
|
validate_new_password(new_password)
|
||||||
|
user.password_hash = hash_password(new_password)
|
||||||
|
user.token_version = (user.token_version or 0) + 1
|
||||||
|
session.add(user)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def link_telegram(session: Session, user: User, identity: ExternalIdentity) -> User:
|
||||||
|
"""Привязать Telegram к существующему аккаунту; ник не меняется.
|
||||||
|
|
||||||
|
После привязки вход через Telegram попадает в этот аккаунт: find_by_identity находит
|
||||||
|
его по той же связке provider+external_id, что создаёт регистрация через Telegram."""
|
||||||
|
already = session.exec(
|
||||||
|
select(AuthIdentity).where(
|
||||||
|
AuthIdentity.user_id == user.id, AuthIdentity.provider == identity.provider
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if user.telegram_id is not None or already is not None:
|
||||||
|
raise TelegramAlreadyLinkedError()
|
||||||
|
taken_by_id = session.exec(select(User).where(User.telegram_id == identity.telegram_id)).first()
|
||||||
|
if find_by_identity(session, identity) is not None or taken_by_id is not None:
|
||||||
|
raise TelegramTakenError()
|
||||||
|
user.telegram_id = identity.telegram_id
|
||||||
|
session.add(user)
|
||||||
|
session.add(
|
||||||
|
AuthIdentity(
|
||||||
|
user_id=user.id, # type: ignore[arg-type]
|
||||||
|
provider=identity.provider,
|
||||||
|
external_id=identity.external_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
def update_nickname(session: Session, user: User, new_nickname: str) -> User:
|
def update_nickname(session: Session, user: User, new_nickname: str) -> User:
|
||||||
new_nickname = (new_nickname or "").strip()
|
new_nickname = (new_nickname or "").strip()
|
||||||
if not nickname_format_ok(new_nickname):
|
if not nickname_format_ok(new_nickname):
|
||||||
|
|||||||
+10
-2
@@ -9,8 +9,16 @@ python -m app.bootstrap
|
|||||||
|
|
||||||
echo "[entrypoint] Запуск сервера…"
|
echo "[entrypoint] Запуск сервера…"
|
||||||
# --proxy-headers + доверие к X-Forwarded-* от реверс-прокси (Caddy на VPS):
|
# --proxy-headers + доверие к X-Forwarded-* от реверс-прокси (Caddy на VPS):
|
||||||
# чтобы приложение знало, что снаружи запрос пришёл по HTTPS.
|
# чтобы приложение знало, что снаружи запрос пришёл по HTTPS и кто реальный клиент.
|
||||||
|
#
|
||||||
|
# forwarded-allow-ips НЕ "*" (#58): при "*" uvicorn брал ЛЕВОЕ значение X-Forwarded-For,
|
||||||
|
# и клиент мог подставить произвольный IP (снятие throttle, порча аудита). Доверяем только
|
||||||
|
# апстримам из приватной сети compose (туннель к Caddy ходит на app:8000) и loopback
|
||||||
|
# (healthcheck) — тогда uvicorn сканирует XFF справа и берёт первый недоверенный адрес,
|
||||||
|
# т.е. реальный, добавленный Caddy. Портов на хост нет, снаружи к :8000 никто не ходит.
|
||||||
|
# Переопределяемо через FORWARDED_ALLOW_IPS, если сеть отличается.
|
||||||
# --timeout-graceful-shutdown: SSE-потоки /api/events сами не закрываются, и без лимита
|
# --timeout-graceful-shutdown: SSE-потоки /api/events сами не закрываются, и без лимита
|
||||||
# остановка ждала бы их до SIGKILL по stop_grace_period (30 с) — без lifespan-shutdown.
|
# остановка ждала бы их до SIGKILL по stop_grace_period (30 с) — без lifespan-shutdown.
|
||||||
|
FORWARDED_ALLOW_IPS="${FORWARDED_ALLOW_IPS:-127.0.0.1,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}"
|
||||||
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 \
|
exec uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 1 \
|
||||||
--proxy-headers --forwarded-allow-ips="*" --timeout-graceful-shutdown 10
|
--proxy-headers --forwarded-allow-ips="$FORWARDED_ALLOW_IPS" --timeout-graceful-shutdown 10
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ from sqlalchemy.pool import StaticPool # noqa: E402
|
|||||||
from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402
|
from sqlmodel import Session, SQLModel, create_engine, select # noqa: E402
|
||||||
|
|
||||||
import app.models # noqa: F401,E402 (регистрация моделей)
|
import app.models # noqa: F401,E402 (регистрация моделей)
|
||||||
|
from app.core.ratelimit import login_throttle # noqa: E402
|
||||||
from app.core.security import hash_password # noqa: E402
|
from app.core.security import hash_password # noqa: E402
|
||||||
|
from app.core.token_revocation import revoked_tokens # noqa: E402
|
||||||
from app.db.session import get_session # noqa: E402
|
from app.db.session import get_session # noqa: E402
|
||||||
from app.main import app # noqa: E402
|
from app.main import app # noqa: E402
|
||||||
from app.models import AuthIdentity, GroupMember, User # noqa: E402
|
from app.models import AuthIdentity, GroupMember, User # noqa: E402
|
||||||
@@ -48,6 +50,8 @@ def client(engine):
|
|||||||
yield s
|
yield s
|
||||||
|
|
||||||
app.dependency_overrides[get_session] = _get_session
|
app.dependency_overrides[get_session] = _get_session
|
||||||
|
login_throttle.clear() # счётчики неудачных входов глобальны для процесса
|
||||||
|
revoked_tokens.clear() # denylist отозванных токенов тоже глобален для процесса
|
||||||
with TestClient(app) as c:
|
with TestClient(app) as c:
|
||||||
yield c
|
yield c
|
||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Хардненинг API: раскрытие схемы закрыто в production, открыто в dev/test (#61, F6)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.core import config
|
||||||
|
from app.main import create_app
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_open_in_development(client: TestClient):
|
||||||
|
# Тесты идут в development (conftest) — схема доступна: нужна для `npm run gen:api`.
|
||||||
|
assert client.get("/api/openapi.json").status_code == 200
|
||||||
|
assert client.get("/api/docs").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_openapi_closed_in_production(monkeypatch):
|
||||||
|
monkeypatch.setattr(config.settings, "app_env", "production")
|
||||||
|
prod_app = create_app()
|
||||||
|
c = TestClient(prod_app)
|
||||||
|
assert c.get("/api/openapi.json").status_code == 404
|
||||||
|
assert c.get("/api/docs").status_code == 404
|
||||||
|
assert c.get("/api/redoc").status_code == 404
|
||||||
@@ -10,8 +10,9 @@ from fastapi.testclient import TestClient
|
|||||||
from tests.conftest import csrf_headers
|
from tests.conftest import csrf_headers
|
||||||
|
|
||||||
|
|
||||||
def test_auth_config_dev_has_both_methods(client: TestClient):
|
def test_auth_config_dev_has_all_methods(client: TestClient):
|
||||||
cfg = client.get("/api/auth/config").json()
|
cfg = client.get("/api/auth/config").json()
|
||||||
|
assert "password" in cfg["methods"]
|
||||||
assert "telegram" in cfg["methods"]
|
assert "telegram" in cfg["methods"]
|
||||||
assert "stub" in cfg["methods"] # dev → доступен вход по нику
|
assert "stub" in cfg["methods"] # dev → доступен вход по нику
|
||||||
|
|
||||||
@@ -21,11 +22,11 @@ def test_enabled_methods_by_env(monkeypatch):
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
monkeypatch.setattr(settings, "app_env", "development")
|
monkeypatch.setattr(settings, "app_env", "development")
|
||||||
assert set(enabled_methods()) == {"telegram", "stub"}
|
assert set(enabled_methods()) == {"password", "telegram", "stub"}
|
||||||
monkeypatch.setattr(settings, "app_env", "test")
|
monkeypatch.setattr(settings, "app_env", "test")
|
||||||
assert enabled_methods() == ["telegram"] # test (прод-клон) → только Telegram
|
assert enabled_methods() == ["password", "telegram"] # test (прод-клон) → без stub
|
||||||
monkeypatch.setattr(settings, "app_env", "production")
|
monkeypatch.setattr(settings, "app_env", "production")
|
||||||
assert enabled_methods() == ["telegram"] # prod → только Telegram
|
assert enabled_methods() == ["password", "telegram"] # prod → без stub
|
||||||
|
|
||||||
|
|
||||||
def test_env_flags_and_db_path(monkeypatch):
|
def test_env_flags_and_db_path(monkeypatch):
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Fail-fast конфигурации: production не стартует с дефолтными секретами (#59, F4)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.core import config
|
||||||
|
|
||||||
|
_STRONG_SECRET = "k" * 40
|
||||||
|
_STRONG_ADMIN_PW = "a-strong-admin-password"
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_rejects_default_secret_key():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
config.Settings(
|
||||||
|
app_env="production",
|
||||||
|
secret_key=config._DEFAULT_SECRET_KEY,
|
||||||
|
admin_password=_STRONG_ADMIN_PW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_rejects_short_secret_key():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
config.Settings(
|
||||||
|
app_env="production",
|
||||||
|
secret_key="too-short",
|
||||||
|
admin_password=_STRONG_ADMIN_PW,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_rejects_default_admin_password():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
config.Settings(
|
||||||
|
app_env="production",
|
||||||
|
secret_key=_STRONG_SECRET,
|
||||||
|
admin_bootstrap_enabled=True,
|
||||||
|
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_accepts_strong_secrets():
|
||||||
|
s = config.Settings(
|
||||||
|
app_env="production",
|
||||||
|
secret_key=_STRONG_SECRET,
|
||||||
|
admin_password=_STRONG_ADMIN_PW,
|
||||||
|
)
|
||||||
|
assert s.is_production
|
||||||
|
|
||||||
|
|
||||||
|
def test_production_skips_admin_check_when_bootstrap_disabled():
|
||||||
|
# Админ управляется вручную (bootstrap off) — дефолтный ADMIN_PASSWORD не блокирует старт.
|
||||||
|
s = config.Settings(
|
||||||
|
app_env="production",
|
||||||
|
secret_key=_STRONG_SECRET,
|
||||||
|
admin_bootstrap_enabled=False,
|
||||||
|
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
||||||
|
)
|
||||||
|
assert s.is_production
|
||||||
|
|
||||||
|
|
||||||
|
def test_development_allows_defaults():
|
||||||
|
s = config.Settings(
|
||||||
|
app_env="development",
|
||||||
|
secret_key=config._DEFAULT_SECRET_KEY,
|
||||||
|
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
||||||
|
)
|
||||||
|
assert s.is_development
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
"""Вход по логину (нику) и паролю: регистрация, вход, защита от перебора."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app.main import app
|
||||||
|
from app.models import User
|
||||||
|
from tests.conftest import csrf_headers
|
||||||
|
from tests.test_auth import _telegram_payload
|
||||||
|
|
||||||
|
PASSWORD = "correct-horse"
|
||||||
|
|
||||||
|
|
||||||
|
def _register(client: TestClient, nickname: str = "Игрок", password: str = PASSWORD):
|
||||||
|
return client.post(
|
||||||
|
"/api/auth/register",
|
||||||
|
json={"nickname": nickname, "password": password},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client: TestClient, nickname: str = "Игрок", password: str = PASSWORD):
|
||||||
|
return client.post(
|
||||||
|
"/api/auth/login",
|
||||||
|
json={"nickname": nickname, "password": password},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Регистрация ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_register_opens_session(client: TestClient, engine):
|
||||||
|
r = _register(client)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
me = r.json()
|
||||||
|
assert me["nickname"] == "Игрок"
|
||||||
|
assert me["auth_provider"] == "local"
|
||||||
|
assert me["has_password"] is True
|
||||||
|
assert client.cookies.get("fs_session")
|
||||||
|
|
||||||
|
assert client.get("/api/users/me").json()["id"] == me["id"]
|
||||||
|
with Session(engine) as s:
|
||||||
|
user = s.get(User, me["id"])
|
||||||
|
assert user.password_hash and PASSWORD not in user.password_hash
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_taken_nickname(client: TestClient):
|
||||||
|
assert _register(client).status_code == 200
|
||||||
|
client.cookies.clear()
|
||||||
|
r = _register(client, password="another-pass")
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["error"]["code"] == "NICKNAME_TAKEN"
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_rejects_bad_passwords(client: TestClient):
|
||||||
|
for bad in ["short", " ", "я" * 37]: # короткий, пробелы, 74 байта UTF-8
|
||||||
|
r = _register(client, password=bad)
|
||||||
|
assert r.status_code == 422, (bad, r.text)
|
||||||
|
assert _register(client, password="x" * 129).status_code == 422 # предел схемы
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_rejects_bad_nickname(client: TestClient):
|
||||||
|
assert _register(client, nickname="x").status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_register_is_throttled_per_ip(client: TestClient, monkeypatch):
|
||||||
|
"""Спам регистраций с одного IP упирается в лимит (#62)."""
|
||||||
|
import app.core.ratelimit as ratelimit
|
||||||
|
from app.auth.password import _REGISTER_IP_LIMIT
|
||||||
|
|
||||||
|
now = [4000.0]
|
||||||
|
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
||||||
|
|
||||||
|
for i in range(_REGISTER_IP_LIMIT):
|
||||||
|
client.cookies.clear()
|
||||||
|
assert _register(client, nickname=f"Ник{i}").status_code == 200
|
||||||
|
client.cookies.clear()
|
||||||
|
blocked = _register(client, nickname="Лишний")
|
||||||
|
assert blocked.status_code == 429
|
||||||
|
assert blocked.json()["error"]["code"] == "TOO_MANY_ATTEMPTS"
|
||||||
|
|
||||||
|
now[0] += 15 * 60 # окно истекло
|
||||||
|
client.cookies.clear()
|
||||||
|
assert _register(client, nickname="ПослеОкна").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Вход ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_login_after_logout(client: TestClient):
|
||||||
|
uid = _register(client).json()["id"]
|
||||||
|
assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
r = _login(client)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["id"] == uid
|
||||||
|
|
||||||
|
|
||||||
|
def test_wrong_password_and_unknown_login_look_the_same(client: TestClient):
|
||||||
|
_register(client)
|
||||||
|
client.cookies.clear()
|
||||||
|
|
||||||
|
wrong = _login(client, password="wrong-password")
|
||||||
|
unknown = _login(client, nickname="Никто")
|
||||||
|
assert wrong.status_code == unknown.status_code == 401
|
||||||
|
assert wrong.json() == unknown.json()
|
||||||
|
assert wrong.json()["error"]["code"] == "INVALID_CREDENTIALS"
|
||||||
|
assert "fs_session" not in client.cookies
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_credentials_do_not_open_player_session(client: TestClient, make_admin):
|
||||||
|
make_admin("boss", "secret123")
|
||||||
|
r = _login(client, nickname="boss", password="secret123")
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert "fs_session" not in client.cookies
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_password_does_not_open_admin_session(client: TestClient):
|
||||||
|
_register(client)
|
||||||
|
client.cookies.clear()
|
||||||
|
r = client.post("/api/admin/auth/login", json={"username": "Игрок", "password": PASSWORD})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_without_password_cannot_log_in(client: TestClient, engine):
|
||||||
|
with Session(engine) as s:
|
||||||
|
s.add(User(nickname="Телеграмщик", role="player", auth_provider="telegram"))
|
||||||
|
s.commit()
|
||||||
|
r = _login(client, nickname="Телеграмщик", password="anything-at-all")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_disabled_account_cannot_log_in(client: TestClient, engine):
|
||||||
|
uid = _register(client).json()["id"]
|
||||||
|
client.cookies.clear()
|
||||||
|
with Session(engine) as s:
|
||||||
|
user = s.get(User, uid)
|
||||||
|
user.is_active = False
|
||||||
|
s.add(user)
|
||||||
|
s.commit()
|
||||||
|
|
||||||
|
r = _login(client)
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json()["error"]["code"] == "ACCOUNT_DISABLED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_login_is_audited_without_secrets(client: TestClient, engine):
|
||||||
|
from app.models import AuditLog
|
||||||
|
|
||||||
|
uid = _register(client).json()["id"]
|
||||||
|
with Session(engine) as s:
|
||||||
|
logs = s.exec(select(AuditLog).where(AuditLog.entity_id == uid)).all()
|
||||||
|
assert {(log.action, (log.payload or {}).get("provider")) for log in logs} >= {
|
||||||
|
("create", "local"),
|
||||||
|
("login", "local"),
|
||||||
|
}
|
||||||
|
assert all(PASSWORD not in str(log.payload) for log in logs)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Защита от перебора ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_throttle_blocks_after_five_failures(client: TestClient, monkeypatch):
|
||||||
|
import app.core.ratelimit as ratelimit
|
||||||
|
|
||||||
|
now = [1000.0]
|
||||||
|
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
||||||
|
|
||||||
|
_register(client)
|
||||||
|
client.cookies.clear()
|
||||||
|
for _ in range(5):
|
||||||
|
assert _login(client, password="wrong-password").status_code == 401
|
||||||
|
|
||||||
|
blocked = _login(client) # даже верный пароль не проверяется
|
||||||
|
assert blocked.status_code == 429
|
||||||
|
err = blocked.json()["error"]
|
||||||
|
assert err["code"] == "TOO_MANY_ATTEMPTS"
|
||||||
|
assert 0 < err["details"]["retry_after"] <= 15 * 60 + 1
|
||||||
|
assert "fs_session" not in client.cookies
|
||||||
|
|
||||||
|
now[0] += 15 * 60 # окно истекло
|
||||||
|
assert _login(client).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_success_resets_pair_counter(client: TestClient):
|
||||||
|
_register(client)
|
||||||
|
client.cookies.clear()
|
||||||
|
for _ in range(4):
|
||||||
|
assert _login(client, password="wrong-password").status_code == 401
|
||||||
|
assert _login(client).status_code == 200
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
for _ in range(4):
|
||||||
|
assert _login(client, password="wrong-password").status_code == 401
|
||||||
|
assert _login(client).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_throttle_per_ip_across_logins(client: TestClient):
|
||||||
|
"""С одного адреса нельзя перебирать пароли по многим логинам: 20 неудач — блок."""
|
||||||
|
for i in range(20):
|
||||||
|
assert _login(client, nickname=f"Логин{i}", password="wrong-password").status_code == 401
|
||||||
|
assert _login(client, nickname="Ещё один", password="wrong-password").status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_scoped_throttle_survives_ip_rotation(client: TestClient, engine, monkeypatch):
|
||||||
|
"""Перебор одного логина с РАЗНЫХ адресов (ротация X-Forwarded-For, #58) упирается в
|
||||||
|
IP-независимый лимит на аккаунт (#60): пара IP+логин и лимит по IP так не копятся."""
|
||||||
|
import app.core.ratelimit as ratelimit
|
||||||
|
from app.auth.password import _ACCOUNT_LIMIT, login_player
|
||||||
|
from app.core.errors import InvalidCredentialsError, TooManyAttemptsError
|
||||||
|
|
||||||
|
now = [3000.0]
|
||||||
|
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
||||||
|
_register(client, nickname="Жертва", password=PASSWORD)
|
||||||
|
|
||||||
|
with Session(engine) as s:
|
||||||
|
for i in range(_ACCOUNT_LIMIT): # каждый раз новый адрес
|
||||||
|
with pytest.raises(InvalidCredentialsError):
|
||||||
|
login_player(s, "Жертва", "wrong-password", ip=f"10.0.{i // 256}.{i % 256}")
|
||||||
|
# ещё одна попытка с совершенно нового адреса — уже блок по лимиту на аккаунт
|
||||||
|
with pytest.raises(TooManyAttemptsError):
|
||||||
|
login_player(s, "Жертва", "wrong-password", ip="203.0.113.7")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Установка и смена пароля ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _set_password(client: TestClient, new: str, current: str | None = None):
|
||||||
|
body = {"new_password": new}
|
||||||
|
if current is not None:
|
||||||
|
body["current_password"] = current
|
||||||
|
return client.put("/api/users/me/password", json=body, headers=csrf_headers(client))
|
||||||
|
|
||||||
|
|
||||||
|
def _telegram_login(client: TestClient, monkeypatch, **fields):
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
||||||
|
return client.post(
|
||||||
|
"/api/auth/telegram",
|
||||||
|
json=_telegram_payload("TEST_BOT_TOKEN", **fields),
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_telegram_user_sets_password_then_logs_in(client: TestClient, monkeypatch):
|
||||||
|
"""Сценарий 1 и существующие аккаунты: без пароля → задаёт без текущего → входит по нику."""
|
||||||
|
r = _telegram_login(client, monkeypatch)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
me = r.json()
|
||||||
|
assert me["has_password"] is False
|
||||||
|
|
||||||
|
r2 = _set_password(client, PASSWORD)
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
assert r2.json()["has_password"] is True
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
r3 = _login(client, nickname=me["nickname"])
|
||||||
|
assert r3.status_code == 200, r3.text
|
||||||
|
assert r3.json()["id"] == me["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_password_requires_current(client: TestClient):
|
||||||
|
_register(client)
|
||||||
|
|
||||||
|
missing = _set_password(client, "new-password-1")
|
||||||
|
assert missing.status_code == 403
|
||||||
|
assert missing.json()["error"]["code"] == "WRONG_CURRENT_PASSWORD"
|
||||||
|
assert _set_password(client, "new-password-1", current="wrong-one").status_code == 403
|
||||||
|
|
||||||
|
assert _set_password(client, "new-password-1", current=PASSWORD).status_code == 200
|
||||||
|
client.cookies.clear()
|
||||||
|
assert _login(client).status_code == 401
|
||||||
|
assert _login(client, password="new-password-1").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_password_validates_new(client: TestClient):
|
||||||
|
_register(client)
|
||||||
|
r = _set_password(client, "short", current=PASSWORD)
|
||||||
|
assert r.status_code == 422
|
||||||
|
client.cookies.clear()
|
||||||
|
assert _login(client).status_code == 200 # старый пароль не тронут
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_password_guessing_is_throttled(client: TestClient):
|
||||||
|
_register(client)
|
||||||
|
for _ in range(5):
|
||||||
|
assert _set_password(client, "new-password-1", current="wrong-one").status_code == 403
|
||||||
|
blocked = _set_password(client, "new-password-1", current=PASSWORD)
|
||||||
|
assert blocked.status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_password_requires_session(client: TestClient):
|
||||||
|
assert _set_password(client, PASSWORD).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Отзыв токена: logout и смена пароля (#57, F2) ────────────────────────────
|
||||||
|
|
||||||
|
def _me_with_token(cookie_name: str, token: str):
|
||||||
|
"""Предъявить конкретный токен вручную (эмуляция «другого устройства»/украденной cookie)."""
|
||||||
|
return TestClient(app).get("/api/users/me", headers={"Cookie": f"{cookie_name}={token}"})
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_revokes_presented_token(client: TestClient):
|
||||||
|
_register(client)
|
||||||
|
tok = client.cookies.get("fs_session")
|
||||||
|
assert _me_with_token("fs_session", tok).status_code == 200 # пока жив
|
||||||
|
|
||||||
|
assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200
|
||||||
|
# тот же токен, предъявленный после выхода, больше не принимается
|
||||||
|
assert _me_with_token("fs_session", tok).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_logout_does_not_revoke_other_devices(client: TestClient):
|
||||||
|
_register(client) # устройство A
|
||||||
|
tok_a = client.cookies.get("fs_session")
|
||||||
|
# устройство B: независимый вход тем же аккаунтом (свой jti)
|
||||||
|
b = TestClient(app)
|
||||||
|
assert b.post("/api/auth/login", json={"nickname": "Игрок", "password": PASSWORD}).status_code == 200
|
||||||
|
tok_b = b.cookies.get("fs_session")
|
||||||
|
assert tok_a and tok_b and tok_a != tok_b
|
||||||
|
|
||||||
|
assert client.post("/api/auth/logout", headers=csrf_headers(client)).status_code == 200
|
||||||
|
assert _me_with_token("fs_session", tok_a).status_code == 401 # A вышел
|
||||||
|
assert _me_with_token("fs_session", tok_b).status_code == 200 # B не тронут
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_change_revokes_old_sessions_keeps_current(client: TestClient):
|
||||||
|
_register(client)
|
||||||
|
old = client.cookies.get("fs_session")
|
||||||
|
assert _set_password(client, "new-password-1", current=PASSWORD).status_code == 200
|
||||||
|
# это устройство осталось в сессии (cookie перевыдан со свежим ver)
|
||||||
|
assert client.get("/api/users/me").status_code == 200
|
||||||
|
# старый токен (другое устройство/утёкший) отозван инкрементом token_version
|
||||||
|
assert _me_with_token("fs_session", old).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_password_reset_revokes_player_sessions(client: TestClient, monkeypatch, make_admin):
|
||||||
|
player = _telegram_login(client, monkeypatch).json()
|
||||||
|
stolen = client.cookies.get("fs_session") # действующая сессия игрока
|
||||||
|
assert _me_with_token("fs_session", stolen).status_code == 200
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
_admin_login(client, make_admin)
|
||||||
|
r = client.put(
|
||||||
|
f"/api/admin/users/{player['id']}/password",
|
||||||
|
json={"new_password": "from-admin-1"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
# сброс пароля админом обрывает прежние сессии игрока (в т.ч. злоумышленника)
|
||||||
|
assert _me_with_token("fs_session", stolen).status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Привязка Telegram ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _link_telegram(client: TestClient, monkeypatch, **fields):
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
||||||
|
return client.post(
|
||||||
|
"/api/users/me/telegram",
|
||||||
|
json=_telegram_payload("TEST_BOT_TOKEN", **fields),
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_telegram_then_login_via_telegram(client: TestClient, monkeypatch):
|
||||||
|
"""Сценарий 2: аккаунт по паролю → привязал Telegram → вход через него в тот же аккаунт."""
|
||||||
|
uid = _register(client).json()["id"]
|
||||||
|
|
||||||
|
r = _link_telegram(client, monkeypatch) # id=777, тег ivan_tg
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["telegram_id"] == 777
|
||||||
|
assert r.json()["nickname"] == "Игрок" # ник не меняется на тег
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
r2 = _telegram_login(client, monkeypatch)
|
||||||
|
assert r2.status_code == 200, r2.text
|
||||||
|
assert r2.json()["id"] == uid
|
||||||
|
assert r2.json()["nickname"] == "Игрок"
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_telegram_taken_by_other_account(client: TestClient, monkeypatch):
|
||||||
|
assert _telegram_login(client, monkeypatch).status_code == 200 # 777 уже чей-то
|
||||||
|
client.cookies.clear()
|
||||||
|
_register(client)
|
||||||
|
|
||||||
|
r = _link_telegram(client, monkeypatch)
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["error"]["code"] == "TELEGRAM_TAKEN"
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_telegram_twice(client: TestClient, monkeypatch):
|
||||||
|
_register(client)
|
||||||
|
assert _link_telegram(client, monkeypatch).status_code == 200
|
||||||
|
r = _link_telegram(client, monkeypatch, id=778)
|
||||||
|
assert r.status_code == 409
|
||||||
|
assert r.json()["error"]["code"] == "TELEGRAM_ALREADY_LINKED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_link_telegram_bad_signature(client: TestClient, monkeypatch):
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
_register(client)
|
||||||
|
monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN")
|
||||||
|
payload = _telegram_payload("TEST_BOT_TOKEN")
|
||||||
|
payload["hash"] = "deadbeef"
|
||||||
|
r = client.post("/api/users/me/telegram", json=payload, headers=csrf_headers(client))
|
||||||
|
assert r.status_code == 401
|
||||||
|
assert client.get("/api/users/me").json()["telegram_id"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Пароль игроку из админки ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _admin_login(client: TestClient, make_admin):
|
||||||
|
make_admin("boss", "secret123")
|
||||||
|
r = client.post(
|
||||||
|
"/api/admin/auth/login",
|
||||||
|
json={"username": "boss", "password": "secret123"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_sets_player_password(client: TestClient, monkeypatch, make_admin):
|
||||||
|
player = _telegram_login(client, monkeypatch).json()
|
||||||
|
client.cookies.clear()
|
||||||
|
_admin_login(client, make_admin)
|
||||||
|
|
||||||
|
r = client.put(
|
||||||
|
f"/api/admin/users/{player['id']}/password",
|
||||||
|
json={"new_password": "from-admin-1"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["has_password"] is True
|
||||||
|
|
||||||
|
client.cookies.clear()
|
||||||
|
assert _login(client, nickname=player["nickname"], password="from-admin-1").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_cannot_set_admin_password(client: TestClient, make_admin):
|
||||||
|
admin_id = _admin_login(client, make_admin)
|
||||||
|
r = client.put(
|
||||||
|
f"/api/admin/users/{admin_id}/password",
|
||||||
|
json={"new_password": "from-admin-1"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_cannot_set_passwords_via_admin(client: TestClient):
|
||||||
|
uid = _register(client).json()["id"]
|
||||||
|
r = client.put(
|
||||||
|
f"/api/admin/users/{uid}/password",
|
||||||
|
json={"new_password": "from-admin-1"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Защита от перебора пароля администратора (#56, F1) ───────────────────────
|
||||||
|
|
||||||
|
def test_admin_login_throttled_after_failures(client: TestClient, make_admin, monkeypatch):
|
||||||
|
import app.core.ratelimit as ratelimit
|
||||||
|
|
||||||
|
now = [2000.0]
|
||||||
|
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
||||||
|
make_admin("boss", "secret123")
|
||||||
|
|
||||||
|
for _ in range(5):
|
||||||
|
r = client.post("/api/admin/auth/login", json={"username": "boss", "password": "nope"})
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
blocked = client.post("/api/admin/auth/login", json={"username": "boss", "password": "secret123"})
|
||||||
|
assert blocked.status_code == 429 # даже верный пароль не проверяется
|
||||||
|
assert blocked.json()["error"]["code"] == "TOO_MANY_ATTEMPTS"
|
||||||
|
|
||||||
|
now[0] += 15 * 60 # окно истекло
|
||||||
|
ok = client.post("/api/admin/auth/login", json={"username": "boss", "password": "secret123"})
|
||||||
|
assert ok.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_failed_admin_login_is_audited_without_password(client: TestClient, make_admin, engine):
|
||||||
|
from app.models import AuditLog
|
||||||
|
|
||||||
|
make_admin("boss", "secret123")
|
||||||
|
assert client.post(
|
||||||
|
"/api/admin/auth/login", json={"username": "boss", "password": "nope-secret-guess"}
|
||||||
|
).status_code == 401
|
||||||
|
|
||||||
|
with Session(engine) as s:
|
||||||
|
logs = s.exec(select(AuditLog).where(AuditLog.action == "login_failed")).all()
|
||||||
|
assert any(
|
||||||
|
log.entity_type == "admin" and (log.payload or {}).get("username") == "boss" for log in logs
|
||||||
|
)
|
||||||
|
assert all("nope-secret-guess" not in str(log.payload) for log in logs)
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Ошибки валидации запроса: всегда 422 в едином конверте и без эха тела запроса."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_json_body_is_422_not_500(client: TestClient):
|
||||||
|
"""HTML-форма шлёт text/plain: тело приходит сырыми bytes, ответ раньше падал в 500."""
|
||||||
|
for path, raw in [
|
||||||
|
("/api/auth/telegram", '{"id": 1}'),
|
||||||
|
("/api/admin/auth/login", '{"username": "a", "password": "b"}'),
|
||||||
|
]:
|
||||||
|
r = client.post(path, content=raw, headers={"Content-Type": "text/plain"})
|
||||||
|
assert r.status_code == 422, (path, r.text)
|
||||||
|
assert r.json()["error"]["code"] == "VALIDATION_ERROR"
|
||||||
|
|
||||||
|
|
||||||
|
def test_validation_error_does_not_echo_body(client: TestClient):
|
||||||
|
secret = "very-secret-password"
|
||||||
|
r = client.post("/api/admin/auth/login", json={"password": secret})
|
||||||
|
assert r.status_code == 422, r.text
|
||||||
|
assert secret not in r.text
|
||||||
|
|
||||||
|
details = r.json()["error"]["details"]
|
||||||
|
assert details and all(set(d) == {"type", "loc", "msg"} for d in details)
|
||||||
|
|
||||||
|
|
||||||
|
def test_validation_error_points_to_field(client: TestClient):
|
||||||
|
r = client.post("/api/admin/auth/login", json={"password": "x"})
|
||||||
|
locs = [d["loc"] for d in r.json()["error"]["details"]]
|
||||||
|
assert ["body", "username"] in locs
|
||||||
+34
-4
@@ -5,8 +5,13 @@
|
|||||||
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST
|
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV/TEST
|
||||||
#
|
#
|
||||||
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
||||||
# приложению добавляет X-Forwarded-Proto=https / X-Forwarded-For / Host —
|
# приложению передаёт X-Forwarded-Proto=https / X-Forwarded-For / Host —
|
||||||
# приложение это учитывает (uvicorn --proxy-headers). Положи файл в /etc/caddy/Caddyfile.
|
# приложение это учитывает (uvicorn --proxy-headers). Положи файл в /etc/caddy/Caddyfile.
|
||||||
|
#
|
||||||
|
# X-Forwarded-For ПЕРЕЗАПИСЫВАЕМ реальным пиром (header_up ... {remote_host}), а не
|
||||||
|
# добавляем: иначе клиент мог бы подставить своё левое значение и подменить IP для
|
||||||
|
# throttle и аудита (#58). Вместе с сужением forwarded-allow-ips в entrypoint.sh это
|
||||||
|
# делает клиентский IP достоверным.
|
||||||
# Сертификаты — см. deploy/vps/README.md (fullchain = leaf + промежуточные одним файлом).
|
# Сертификаты — см. deploy/vps/README.md (fullchain = leaf + промежуточные одним файлом).
|
||||||
#
|
#
|
||||||
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/
|
# SSE (/api/events): отдельный handle БЕЗ encode и с flush_interval -1 — иначе сжатие/
|
||||||
@@ -18,8 +23,27 @@
|
|||||||
# иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит.
|
# иначе браузер отдаёт старый index.html из кэша и до Caddy/заглушки запрос не доходит.
|
||||||
# Файл заглушки — deploy/vps/maintenance.html.
|
# Файл заглушки — deploy/vps/maintenance.html.
|
||||||
|
|
||||||
# Edge-поведение, общее для сайтов: запрет кэша HTML-документа + заглушка при падении апстрима.
|
# Edge-поведение, общее для сайтов: security-заголовки + запрет кэша HTML + заглушка при падении апстрима.
|
||||||
(edge) {
|
(edge) {
|
||||||
|
# Security-заголовки (#61). HSTS — принудительный HTTPS на год с поддоменами; nosniff —
|
||||||
|
# запрет MIME-sniffing; frame DENY — защита от кликджекинга (наши страницы нельзя встроить
|
||||||
|
# в чужой iframe); Referrer/Permissions — минимизация утечек. Server скрываем, чтобы не
|
||||||
|
# светить используемый прокси.
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "DENY"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
Permissions-Policy "geolocation=(), microphone=(), camera=()"
|
||||||
|
-Server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Content-Security-Policy подготовлена, но ВЫКЛЮЧЕНА до проверки на test-клоне: строгая
|
||||||
|
# политика легко ломает SPA (инлайновые стили Vite), Telegram-виджет входа (скрипт с
|
||||||
|
# telegram.org + iframe oauth.telegram.org) и EventSource (/api/events). Раскомментировать
|
||||||
|
# после проверки на forbidden-stars.ru, что вход и реал-тайм работают (#61).
|
||||||
|
# header Content-Security-Policy "default-src 'self'; script-src 'self' https://telegram.org https://oauth.telegram.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self'; frame-src https://oauth.telegram.org; font-src 'self' data:; base-uri 'self'; form-action 'self'; frame-ancestors 'none'"
|
||||||
|
|
||||||
# HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый
|
# HTML-документ (навигации, Accept: text/html) НЕ кэшируем. Иначе браузер отдаёт старый
|
||||||
# SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно.
|
# SPA из кэша без сетевого запроса → запрос не доходит до Caddy и заглушку не видно.
|
||||||
# Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно.
|
# Хэшированные ассеты (JS/CSS) под это не попадают (у них другой Accept) и кэшируются как обычно.
|
||||||
@@ -46,11 +70,14 @@ forbiddenstars.ru {
|
|||||||
handle @sse {
|
handle @sse {
|
||||||
reverse_proxy 127.0.0.1:9000 {
|
reverse_proxy 127.0.0.1:9000 {
|
||||||
flush_interval -1
|
flush_interval -1
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handle {
|
handle {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
reverse_proxy 127.0.0.1:9000
|
reverse_proxy 127.0.0.1:9000 {
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
import edge
|
import edge
|
||||||
}
|
}
|
||||||
@@ -61,11 +88,14 @@ forbidden-stars.ru {
|
|||||||
handle @sse {
|
handle @sse {
|
||||||
reverse_proxy 127.0.0.1:9001 {
|
reverse_proxy 127.0.0.1:9001 {
|
||||||
flush_interval -1
|
flush_interval -1
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
handle {
|
handle {
|
||||||
encode zstd gzip
|
encode zstd gzip
|
||||||
reverse_proxy 127.0.0.1:9001
|
reverse_proxy 127.0.0.1:9001 {
|
||||||
|
header_up X-Forwarded-For {remote_host}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
import edge
|
import edge
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+303
@@ -21,6 +21,46 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/auth/register": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Password Register
|
||||||
|
* @description Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле.
|
||||||
|
*/
|
||||||
|
post: operations["password_register_api_auth_register_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/auth/login": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Password Login
|
||||||
|
* @description Вход игрока по нику и паролю. Неверная пара — 401, перебор — 429.
|
||||||
|
*/
|
||||||
|
post: operations["password_login_api_auth_login_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/auth/telegram": {
|
"/api/auth/telegram": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -122,6 +162,46 @@ export interface paths {
|
|||||||
patch: operations["update_my_profile_api_users_me_profile_patch"];
|
patch: operations["update_my_profile_api_users_me_profile_patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/users/me/password": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/**
|
||||||
|
* Change My Password
|
||||||
|
* @description Задать пароль (первый раз — без текущего) или сменить его (нужен текущий).
|
||||||
|
*/
|
||||||
|
put: operations["change_my_password_api_users_me_password_put"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/users/me/telegram": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Link My Telegram
|
||||||
|
* @description Привязать Telegram к аккаунту. Подпись виджета проверяется так же, как при входе.
|
||||||
|
*/
|
||||||
|
post: operations["link_my_telegram_api_users_me_telegram_post"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/users/me/avatar": {
|
"/api/users/me/avatar": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -844,6 +924,26 @@ export interface paths {
|
|||||||
patch: operations["update_user_api_admin_users__user_id__patch"];
|
patch: operations["update_user_api_admin_users__user_id__patch"];
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/admin/users/{user_id}/password": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/**
|
||||||
|
* Set User Password
|
||||||
|
* @description Задать игроку новый пароль — когда он забыл свой. Сам пароль в аудит не пишется.
|
||||||
|
*/
|
||||||
|
put: operations["set_user_password_api_admin_users__user_id__password_put"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/admin/groups": {
|
"/api/admin/groups": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1224,6 +1324,11 @@ export interface components {
|
|||||||
/** Role */
|
/** Role */
|
||||||
role: string;
|
role: string;
|
||||||
};
|
};
|
||||||
|
/** AdminPasswordSet */
|
||||||
|
AdminPasswordSet: {
|
||||||
|
/** New Password */
|
||||||
|
new_password: string;
|
||||||
|
};
|
||||||
/** AdminUserRead */
|
/** AdminUserRead */
|
||||||
AdminUserRead: {
|
AdminUserRead: {
|
||||||
/** Id */
|
/** Id */
|
||||||
@@ -1240,6 +1345,11 @@ export interface components {
|
|||||||
telegram_id?: number | null;
|
telegram_id?: number | null;
|
||||||
/** Created At */
|
/** Created At */
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
/**
|
||||||
|
* Has Password
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
has_password: boolean;
|
||||||
};
|
};
|
||||||
/** AdminUserUpdate */
|
/** AdminUserUpdate */
|
||||||
AdminUserUpdate: {
|
AdminUserUpdate: {
|
||||||
@@ -1887,6 +1997,11 @@ export interface components {
|
|||||||
* @default []
|
* @default []
|
||||||
*/
|
*/
|
||||||
groups: components["schemas"]["GroupBrief"][];
|
groups: components["schemas"]["GroupBrief"][];
|
||||||
|
/**
|
||||||
|
* Has Password
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
has_password: boolean;
|
||||||
};
|
};
|
||||||
/** MemberAdd */
|
/** MemberAdd */
|
||||||
MemberAdd: {
|
MemberAdd: {
|
||||||
@@ -1990,6 +2105,27 @@ export interface components {
|
|||||||
/** Comment */
|
/** Comment */
|
||||||
comment?: string | null;
|
comment?: string | null;
|
||||||
};
|
};
|
||||||
|
/** PasswordChange */
|
||||||
|
PasswordChange: {
|
||||||
|
/** Current Password */
|
||||||
|
current_password?: string | null;
|
||||||
|
/** New Password */
|
||||||
|
new_password: string;
|
||||||
|
};
|
||||||
|
/** PasswordLogin */
|
||||||
|
PasswordLogin: {
|
||||||
|
/** Nickname */
|
||||||
|
nickname: string;
|
||||||
|
/** Password */
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
/** PasswordRegister */
|
||||||
|
PasswordRegister: {
|
||||||
|
/** Nickname */
|
||||||
|
nickname: string;
|
||||||
|
/** Password */
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
/** ProfileStats */
|
/** ProfileStats */
|
||||||
ProfileStats: {
|
ProfileStats: {
|
||||||
/** User Id */
|
/** User Id */
|
||||||
@@ -2198,6 +2334,72 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
password_register_api_auth_register_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PasswordRegister"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MeRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
password_login_api_auth_login_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PasswordLogin"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MeRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
telegram_login_api_auth_telegram_post: {
|
telegram_login_api_auth_telegram_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2370,6 +2572,72 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
change_my_password_api_users_me_password_put: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PasswordChange"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MeRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
link_my_telegram_api_users_me_telegram_post: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["TelegramAuthPayload"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["MeRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
upload_my_avatar_api_users_me_avatar_put: {
|
upload_my_avatar_api_users_me_avatar_put: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3789,6 +4057,41 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
set_user_password_api_admin_users__user_id__password_put: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
user_id: number;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AdminPasswordSet"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Successful Response */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AdminUserRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Validation Error */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["HTTPValidationError"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
list_groups_api_admin_groups_get: {
|
list_groups_api_admin_groups_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useServerEvents } from "../hooks/useServerEvents";
|
|||||||
import { BottomBar } from "./BottomBar";
|
import { BottomBar } from "./BottomBar";
|
||||||
import { NotificationBell } from "./NotificationBell";
|
import { NotificationBell } from "./NotificationBell";
|
||||||
import { NotificationToaster } from "./NotificationToaster";
|
import { NotificationToaster } from "./NotificationToaster";
|
||||||
|
import { SetPasswordDialog } from "./SetPasswordDialog";
|
||||||
import { SideMenu } from "./SideMenu";
|
import { SideMenu } from "./SideMenu";
|
||||||
|
|
||||||
const TITLES: Record<string, string> = {
|
const TITLES: Record<string, string> = {
|
||||||
@@ -51,6 +52,9 @@ export function AppShell() {
|
|||||||
|
|
||||||
<SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
|
<SideMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
|
||||||
<BottomBar onMenu={() => setMenuOpen(true)} />
|
<BottomBar onMenu={() => setMenuOpen(true)} />
|
||||||
|
|
||||||
|
{/* Без пароля дальше не пускаем: логин = ник, пароль нужен для входа. */}
|
||||||
|
{me && !me.has_password && <SetPasswordDialog nickname={me.nickname} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import { useToast } from "../context/ToastContext";
|
|
||||||
import { useCreateGroup } from "../hooks/groups";
|
import { useCreateGroup } from "../hooks/groups";
|
||||||
import { useExpansions } from "../hooks/reference";
|
import { useExpansions } from "../hooks/reference";
|
||||||
import { Switch } from "./Switch";
|
import { Switch } from "./Switch";
|
||||||
@@ -9,7 +8,6 @@ import { Switch } from "./Switch";
|
|||||||
export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
||||||
const { data: expansions } = useExpansions();
|
const { data: expansions } = useExpansions();
|
||||||
const createGroup = useCreateGroup();
|
const createGroup = useCreateGroup();
|
||||||
const toast = useToast();
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -25,7 +23,6 @@ export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
await createGroup.mutateAsync({ name: name.trim(), expansion_ids: [...selected] });
|
await createGroup.mutateAsync({ name: name.trim(), expansion_ids: [...selected] });
|
||||||
toast.show("Группа создана");
|
|
||||||
onCreated?.();
|
onCreated?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.message : "Не удалось создать группу");
|
setError(e instanceof ApiError ? e.message : "Не удалось создать группу");
|
||||||
|
|||||||
@@ -15,16 +15,15 @@ export function GroupInvitations() {
|
|||||||
await accept.mutateAsync(id);
|
await accept.mutateAsync(id);
|
||||||
toast.show("Вы вступили в группу");
|
toast.show("Вы вступили в группу");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDecline = async (id: number) => {
|
const onDecline = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
await decline.mutateAsync(id);
|
await decline.mutateAsync(id);
|
||||||
toast.show("Приглашение отклонено");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -35,14 +35,14 @@ export function MatchMedia({
|
|||||||
let slots = max - attachments.length;
|
let slots = max - attachments.length;
|
||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
if (slots <= 0) {
|
if (slots <= 0) {
|
||||||
toast.show(`Максимум ${max} фото`);
|
toast.error(`Максимум ${max} фото`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await onUpload(f);
|
await onUpload(f);
|
||||||
slots--;
|
slots--;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ export function MatchMedia({
|
|||||||
try {
|
try {
|
||||||
await onDelete(id);
|
await onDelete(id);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
|
toast.error(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { useNotifications, type Notification } from "../hooks/notifications";
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import { useNotifications } from "../hooks/notifications";
|
||||||
const TOAST_TTL_MS = 5000;
|
|
||||||
const MAX_VISIBLE = 4;
|
|
||||||
|
|
||||||
/** Всплывашки сверху экрана в момент прихода новых уведомлений (push-to-invalidate → pull).
|
/** Всплывашки сверху экрана в момент прихода новых уведомлений (push-to-invalidate → pull).
|
||||||
* Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id. */
|
* Первый снимок списка не всплывает (чтобы не спамить при загрузке) — только новые id.
|
||||||
|
* Показывает их общая стопка ToastProvider — там же, где сообщения приложения. */
|
||||||
export function NotificationToaster({ enabled }: { enabled: boolean }) {
|
export function NotificationToaster({ enabled }: { enabled: boolean }) {
|
||||||
const { data } = useNotifications(enabled);
|
const { data } = useNotifications(enabled);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const toast = useToast();
|
||||||
const seen = useRef<Set<number> | null>(null);
|
const seen = useRef<Set<number> | null>(null);
|
||||||
const [toasts, setToasts] = useState<Notification[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
@@ -22,29 +21,16 @@ export function NotificationToaster({ enabled }: { enabled: boolean }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fresh = items.filter((n) => !seen.current!.has(n.id));
|
const fresh = items.filter((n) => !seen.current!.has(n.id));
|
||||||
if (fresh.length === 0) return;
|
|
||||||
fresh.forEach((n) => seen.current!.add(n.id));
|
fresh.forEach((n) => seen.current!.add(n.id));
|
||||||
setToasts((prev) => [...fresh, ...prev].slice(0, MAX_VISIBLE));
|
// Стопка добавляет новое наверх, поэтому идём с конца: первое в списке окажется сверху.
|
||||||
fresh.forEach((n) => {
|
[...fresh].reverse().forEach((n) =>
|
||||||
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== n.id)), TOAST_TTL_MS);
|
toast.notify({
|
||||||
});
|
title: n.title,
|
||||||
}, [data]);
|
body: n.body,
|
||||||
|
onClick: n.link ? () => navigate(n.link!) : undefined,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, [data, toast, navigate]);
|
||||||
|
|
||||||
if (!enabled || toasts.length === 0) return null;
|
return null;
|
||||||
|
|
||||||
const click = (n: Notification) => {
|
|
||||||
setToasts((prev) => prev.filter((t) => t.id !== n.id));
|
|
||||||
if (n.link) navigate(n.link);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="toast-top-wrap">
|
|
||||||
{toasts.map((n) => (
|
|
||||||
<button key={n.id} className="toast-top" onClick={() => click(n)}>
|
|
||||||
<div className="toast-top-title">{n.title}</div>
|
|
||||||
{n.body && <div className="toast-top-body">{n.body}</div>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { ApiError } from "../api/client";
|
||||||
|
import { useLogout, useSetPassword } from "../hooks/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обязательная установка пароля — для аккаунтов из Telegram и созданных до появления
|
||||||
|
* паролей. Закрыть окно нельзя: только задать пароль или выйти. Кнопка «Позже» есть
|
||||||
|
* лишь в dev-сборке (import.meta.env.DEV) — чтобы тестовые stub-игроки не упирались в окно.
|
||||||
|
*/
|
||||||
|
export function SetPasswordDialog({ nickname }: { nickname: string }) {
|
||||||
|
const setPassword = useSetPassword();
|
||||||
|
const logout = useLogout();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [password, setPasswordValue] = useState("");
|
||||||
|
const [repeat, setRepeat] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [skipped, setSkipped] = useState(false);
|
||||||
|
|
||||||
|
if (skipped) return null;
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (password !== repeat) {
|
||||||
|
setError("Пароли не совпадают.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Успех кладёт в кэш профиль с has_password=true — окно исчезает само.
|
||||||
|
await setPassword.mutateAsync({ new_password: password });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Не удалось сохранить пароль");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doLogout = async () => {
|
||||||
|
await logout.mutateAsync().catch(() => {});
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-overlay">
|
||||||
|
<form className="modal" onSubmit={submit}>
|
||||||
|
<div className="modal-title">Задайте пароль</div>
|
||||||
|
<p className="modal-msg">
|
||||||
|
Теперь на сайт входят по логину и паролю. Ваш логин — никнейм <b>{nickname}</b>.
|
||||||
|
Придумайте пароль, чтобы продолжить; вход через Telegram тоже останется.
|
||||||
|
</p>
|
||||||
|
{/* Скрытый логин — чтобы менеджер паролей сохранил пару целиком. */}
|
||||||
|
<input type="text" name="username" autoComplete="username" value={nickname} readOnly hidden />
|
||||||
|
<div className="field">
|
||||||
|
<label className="label" htmlFor="set-password">
|
||||||
|
Пароль (не короче 8 символов)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="set-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
autoFocus
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPasswordValue(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="label" htmlFor="set-password-repeat">
|
||||||
|
Повторите пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="set-password-repeat"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={repeat}
|
||||||
|
onChange={(e) => setRepeat(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
<div className="modal-actions">
|
||||||
|
{import.meta.env.DEV && (
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={() => setSkipped(true)}>
|
||||||
|
Позже (dev)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={doLogout}>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={setPassword.isPending || !password}>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,31 +1,97 @@
|
|||||||
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
|
|
||||||
interface ToastCtx {
|
// Сообщения приложения и входящие уведомления — одна стопка всплывашек сверху экрана.
|
||||||
show: (message: string) => void;
|
type ToastKind = "info" | "error" | "notification";
|
||||||
|
|
||||||
|
interface ToastItem {
|
||||||
|
id: number;
|
||||||
|
kind: ToastKind;
|
||||||
|
title: string;
|
||||||
|
body?: string | null;
|
||||||
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Ctx = createContext<ToastCtx>({ show: () => {} });
|
interface ToastCtx {
|
||||||
|
/** Нейтральное сообщение: действие прошло, но на экране это не видно. */
|
||||||
|
show: (message: string) => void;
|
||||||
|
/** Ошибка или отказ — красная полоса, держится дольше. */
|
||||||
|
error: (message: string) => void;
|
||||||
|
/** Входящее уведомление (заголовок, текст, переход по клику). */
|
||||||
|
notify: (n: { title: string; body?: string | null; onClick?: () => void }) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TTL_MS: Record<ToastKind, number> = { info: 3000, error: 5000, notification: 5000 };
|
||||||
|
const MAX_VISIBLE = 4;
|
||||||
|
|
||||||
|
const Ctx = createContext<ToastCtx>({ show: () => {}, error: () => {}, notify: () => {} });
|
||||||
|
|
||||||
export function ToastProvider({ children }: PropsWithChildren) {
|
export function ToastProvider({ children }: PropsWithChildren) {
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [items, setItems] = useState<ToastItem[]>([]);
|
||||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const nextId = useRef(1);
|
||||||
|
const timers = useRef(new Map<number, ReturnType<typeof setTimeout>>());
|
||||||
|
|
||||||
const show = useCallback((msg: string) => {
|
useEffect(() => {
|
||||||
setMessage(msg);
|
const pending = timers.current;
|
||||||
if (timer.current) clearTimeout(timer.current);
|
return () => pending.forEach(clearTimeout);
|
||||||
timer.current = setTimeout(() => setMessage(null), 2800);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Провайдер обёрнут вокруг всего приложения и перерисовывается на каждый тост:
|
const dismiss = useCallback((id: number) => {
|
||||||
|
clearTimeout(timers.current.get(id));
|
||||||
|
timers.current.delete(id);
|
||||||
|
setItems((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const push = useCallback(
|
||||||
|
(item: Omit<ToastItem, "id">) => {
|
||||||
|
const id = nextId.current++;
|
||||||
|
setItems((prev) => {
|
||||||
|
// Тот же текст уже на экране (двойной клик, повтор ошибки) — заменяем, а не копим.
|
||||||
|
// Уведомления не схлопываем: одинаковый заголовок бывает у разных событий.
|
||||||
|
const rest =
|
||||||
|
item.kind === "notification"
|
||||||
|
? prev
|
||||||
|
: prev.filter((t) => !(t.kind === item.kind && t.title === item.title));
|
||||||
|
return [{ ...item, id }, ...rest].slice(0, MAX_VISIBLE);
|
||||||
|
});
|
||||||
|
timers.current.set(id, setTimeout(() => dismiss(id), TTL_MS[item.kind]));
|
||||||
|
},
|
||||||
|
[dismiss],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Провайдер обёрнут вокруг всего приложения и перерисовывается на каждую всплывашку:
|
||||||
// без useMemo новое значение контекста заставляло бы перерисоваться и всех
|
// без useMemo новое значение контекста заставляло бы перерисоваться и всех
|
||||||
// потребителей, к самому тосту отношения не имеющих.
|
// потребителей, к самим всплывашкам отношения не имеющих.
|
||||||
const value = useMemo(() => ({ show }), [show]);
|
const value = useMemo<ToastCtx>(
|
||||||
|
() => ({
|
||||||
|
show: (message) => push({ kind: "info", title: message }),
|
||||||
|
error: (message) => push({ kind: "error", title: message }),
|
||||||
|
notify: ({ title, body, onClick }) => push({ kind: "notification", title, body, onClick }),
|
||||||
|
}),
|
||||||
|
[push],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Ctx.Provider value={value}>
|
<Ctx.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
{message && <div className="toast">{message}</div>}
|
{/* Контейнер есть всегда: aria-live объявляет только то, что появилось внутри уже
|
||||||
|
существующей области. */}
|
||||||
|
<div className="toast-top-wrap" aria-live="polite">
|
||||||
|
{items.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
type="button"
|
||||||
|
className={"toast-top" + (t.kind === "error" ? " toast-top-error" : "")}
|
||||||
|
onClick={() => {
|
||||||
|
dismiss(t.id);
|
||||||
|
t.onClick?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="toast-top-title">{t.title}</div>
|
||||||
|
{t.body && <div className="toast-top-body">{t.body}</div>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</Ctx.Provider>
|
</Ctx.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,21 @@ export function useAdminUpdateUser() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Новый пароль игроку — когда он забыл свой (почту приложение не хранит).
|
||||||
|
export function useAdminSetPassword() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (args: { userId: number; password: string }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.PUT("/api/admin/users/{user_id}/password", {
|
||||||
|
params: { path: { user_id: args.userId } },
|
||||||
|
body: { new_password: args.password },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Ачивки (редактор; выдача игрокам — на будущее) ──────────────────────────
|
// ─── Ачивки (редактор; выдача игрокам — на будущее) ──────────────────────────
|
||||||
|
|
||||||
interface AchievementInput {
|
interface AchievementInput {
|
||||||
|
|||||||
@@ -45,6 +45,52 @@ export function useStubLogin() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Вход по логину (нику) и паролю. Перебор сервер ограничивает: 429 TOO_MANY_ATTEMPTS.
|
||||||
|
export function usePasswordLogin() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: { nickname: string; password: string }) =>
|
||||||
|
unwrap(await api.POST("/api/auth/login", { body })),
|
||||||
|
onSuccess: (me) => {
|
||||||
|
qc.setQueryData(qk.me, me);
|
||||||
|
qc.invalidateQueries();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Регистрация по логину (нику) и паролю; Telegram привязывается позже в профиле.
|
||||||
|
export function useRegister() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: { nickname: string; password: string }) =>
|
||||||
|
unwrap(await api.POST("/api/auth/register", { body })),
|
||||||
|
onSuccess: (me) => {
|
||||||
|
qc.setQueryData(qk.me, me);
|
||||||
|
qc.invalidateQueries();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Задать пароль (первый раз — без текущего) или сменить его (нужен текущий).
|
||||||
|
export function useSetPassword() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (body: { new_password: string; current_password?: string }): Promise<Me> =>
|
||||||
|
unwrap(await api.PUT("/api/users/me/password", { body })),
|
||||||
|
onSuccess: (me) => qc.setQueryData(qk.me, me),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Привязка Telegram к аккаунту: тот же payload виджета, что при входе; ник не меняется.
|
||||||
|
export function useLinkTelegram() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (payload: Record<string, unknown>): Promise<Me> =>
|
||||||
|
unwrap(await api.POST("/api/users/me/telegram", { body: payload as never })),
|
||||||
|
onSuccess: (me) => qc.setQueryData(qk.me, me),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC.
|
// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC.
|
||||||
// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED —
|
// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED —
|
||||||
// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета).
|
// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета).
|
||||||
|
|||||||
@@ -8,11 +8,16 @@ import { PickerSelect } from "../components/PickerSelect";
|
|||||||
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
import { ProfileStatsCard } from "../components/ProfileStatsCard";
|
||||||
import { Spinner } from "../components/Spinner";
|
import { Spinner } from "../components/Spinner";
|
||||||
import { Switch } from "../components/Switch";
|
import { Switch } from "../components/Switch";
|
||||||
|
import { TelegramLoginButton } from "../components/TelegramLoginButton";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
|
import type { Me } from "../domain/types";
|
||||||
import {
|
import {
|
||||||
|
useAuthConfig,
|
||||||
useDeleteAvatar,
|
useDeleteAvatar,
|
||||||
|
useLinkTelegram,
|
||||||
useLogout,
|
useLogout,
|
||||||
useMe,
|
useMe,
|
||||||
|
useSetPassword,
|
||||||
useUpdateBio,
|
useUpdateBio,
|
||||||
useUpdateFavoriteFaction,
|
useUpdateFavoriteFaction,
|
||||||
useUpdateHistoryPrefs,
|
useUpdateHistoryPrefs,
|
||||||
@@ -60,9 +65,8 @@ export function AccountPage() {
|
|||||||
const saveFavorite = async (factionId: number | null) => {
|
const saveFavorite = async (factionId: number | null) => {
|
||||||
try {
|
try {
|
||||||
await updateFavorite.mutateAsync(factionId);
|
await updateFavorite.mutateAsync(factionId);
|
||||||
toast.show(factionId == null ? "Любимая фракция убрана" : "Любимая фракция сохранена");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,7 +74,7 @@ export function AccountPage() {
|
|||||||
try {
|
try {
|
||||||
await updateHistory.mutateAsync(prefs);
|
await updateHistory.mutateAsync(prefs);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -80,9 +84,8 @@ export function AccountPage() {
|
|||||||
try {
|
try {
|
||||||
await updateNick.mutateAsync(nick);
|
await updateNick.mutateAsync(nick);
|
||||||
setNickname("");
|
setNickname("");
|
||||||
toast.show("Никнейм обновлён");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,7 +94,7 @@ export function AccountPage() {
|
|||||||
await updateBio.mutateAsync(bioValue.trim());
|
await updateBio.mutateAsync(bioValue.trim());
|
||||||
toast.show("Сохранено");
|
toast.show("Сохранено");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -101,18 +104,16 @@ export function AccountPage() {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
await uploadAvatar.mutateAsync(file);
|
await uploadAvatar.mutateAsync(file);
|
||||||
toast.show("Аватар обновлён");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeAvatar = async () => {
|
const removeAvatar = async () => {
|
||||||
try {
|
try {
|
||||||
await deleteAvatar.mutateAsync();
|
await deleteAvatar.mutateAsync();
|
||||||
toast.show("Аватар удалён");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -218,6 +219,9 @@ export function AccountPage() {
|
|||||||
Сохранить
|
Сохранить
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="muted small" style={{ marginTop: 6 }}>
|
||||||
|
Никнейм — это и логин для входа: после смены входите под новым.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -256,9 +260,128 @@ export function AccountPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<LoginSettingsCard me={me} />
|
||||||
|
|
||||||
<button className="btn btn-danger btn-block" onClick={doLogout}>
|
<button className="btn btn-danger btn-block" onClick={doLogout}>
|
||||||
Выйти
|
Выйти
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Вход в аккаунт: смена пароля и привязка Telegram (ник при привязке не меняется).
|
||||||
|
function LoginSettingsCard({ me }: { me: Me }) {
|
||||||
|
const setPassword = useSetPassword();
|
||||||
|
const linkTelegram = useLinkTelegram();
|
||||||
|
const { data: config } = useAuthConfig();
|
||||||
|
const toast = useToast();
|
||||||
|
const [current, setCurrent] = useState("");
|
||||||
|
const [next, setNext] = useState("");
|
||||||
|
const [repeat, setRepeat] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const changePassword = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (next !== repeat) {
|
||||||
|
setError("Пароли не совпадают.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await setPassword.mutateAsync(
|
||||||
|
me.has_password ? { current_password: current, new_password: next } : { new_password: next },
|
||||||
|
);
|
||||||
|
setCurrent("");
|
||||||
|
setNext("");
|
||||||
|
setRepeat("");
|
||||||
|
toast.show(me.has_password ? "Пароль изменён" : "Пароль задан");
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Не удалось сохранить пароль");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTelegram = async (payload: Record<string, unknown>) => {
|
||||||
|
try {
|
||||||
|
await linkTelegram.mutateAsync(payload);
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof ApiError ? err.message : "Не удалось привязать Telegram");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const telegramReady = !!config?.telegram_bot_username && config.methods.includes("telegram");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<h3>Вход в аккаунт</h3>
|
||||||
|
<form onSubmit={changePassword}>
|
||||||
|
{/* Скрытый логин — чтобы менеджер паролей обновил сохранённую пару. */}
|
||||||
|
<input type="text" name="username" autoComplete="username" value={me.nickname} readOnly hidden />
|
||||||
|
{me.has_password && (
|
||||||
|
<div className="field">
|
||||||
|
<label className="label" htmlFor="current-password">
|
||||||
|
Текущий пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="current-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={current}
|
||||||
|
onChange={(e) => setCurrent(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="field">
|
||||||
|
<label className="label" htmlFor="new-password">
|
||||||
|
Новый пароль (не короче 8 символов)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={next}
|
||||||
|
onChange={(e) => setNext(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="label" htmlFor="new-password-repeat">
|
||||||
|
Повторите новый пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="new-password-repeat"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={repeat}
|
||||||
|
onChange={(e) => setRepeat(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={setPassword.isPending || !next || (me.has_password && !current)}
|
||||||
|
>
|
||||||
|
{me.has_password ? "Сменить пароль" : "Задать пароль"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="field" style={{ marginTop: 16, marginBottom: 0 }}>
|
||||||
|
<label className="label">Telegram</label>
|
||||||
|
{me.telegram_id != null ? (
|
||||||
|
<div>Привязан — можно входить и через Telegram.</div>
|
||||||
|
) : telegramReady ? (
|
||||||
|
<>
|
||||||
|
<p className="muted small" style={{ marginTop: 0 }}>
|
||||||
|
Привяжите Telegram, чтобы входить и через него. Никнейм не изменится.
|
||||||
|
</p>
|
||||||
|
<TelegramLoginButton
|
||||||
|
botUsername={config?.telegram_bot_username ?? ""}
|
||||||
|
onAuth={(u) => onTelegram(u as unknown as Record<string, unknown>)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="muted small">Вход через Telegram не настроен.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ export function CreateMatchPage() {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]);
|
if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]);
|
||||||
setSpin((s) => ({ ...s, [id]: null }));
|
setSpin((s) => ({ ...s, [id]: null }));
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка рандома");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -176,7 +176,6 @@ export function CreateMatchPage() {
|
|||||||
was_random: r.was_random,
|
was_random: r.was_random,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
toast.show("Партия начата");
|
|
||||||
navigate(`/match/${match.id}`, { replace: true });
|
navigate(`/match/${match.id}`, { replace: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.message : "Не удалось начать партию");
|
setError(e instanceof ApiError ? e.message : "Не удалось начать партию");
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function GroupPage() {
|
|||||||
setNewNick("");
|
setNewNick("");
|
||||||
toast.show("Приглашение отправлено");
|
toast.show("Приглашение отправлено");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function GroupSettingsPage() {
|
|||||||
setName(null);
|
setName(null);
|
||||||
toast.show("Название сохранено");
|
toast.show("Название сохранено");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,7 +68,7 @@ export function GroupSettingsPage() {
|
|||||||
await setExpansions.mutateAsync([...selected]);
|
await setExpansions.mutateAsync([...selected]);
|
||||||
toast.show("Дополнения сохранены");
|
toast.show("Дополнения сохранены");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ export function GroupSettingsPage() {
|
|||||||
try {
|
try {
|
||||||
await removeMember.mutateAsync(userId);
|
await removeMember.mutateAsync(userId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
useCreateDevUser,
|
useCreateDevUser,
|
||||||
useDevUsers,
|
useDevUsers,
|
||||||
useMe,
|
useMe,
|
||||||
|
usePasswordLogin,
|
||||||
|
useRegister,
|
||||||
useStubLogin,
|
useStubLogin,
|
||||||
useTelegramLogin,
|
useTelegramLogin,
|
||||||
useTelegramRegister,
|
useTelegramRegister,
|
||||||
@@ -28,6 +30,7 @@ export function LoginPage() {
|
|||||||
const [pendingTg, setPendingTg] = useState<PendingTelegram | null>(null);
|
const [pendingTg, setPendingTg] = useState<PendingTelegram | null>(null);
|
||||||
|
|
||||||
const methods = config?.methods ?? [];
|
const methods = config?.methods ?? [];
|
||||||
|
const hasPassword = methods.includes("password");
|
||||||
const hasTelegram = methods.includes("telegram");
|
const hasTelegram = methods.includes("telegram");
|
||||||
// Stub-вход показываем только в dev-сборке И если бэкенд его отдаёт (в проде
|
// Stub-вход показываем только в dev-сборке И если бэкенд его отдаёт (в проде
|
||||||
// import.meta.env.DEV === false → блок вырезается из бандла).
|
// import.meta.env.DEV === false → блок вырезается из бандла).
|
||||||
@@ -72,22 +75,29 @@ export function LoginPage() {
|
|||||||
onDone={() => navigate("/", { replace: true })}
|
onDone={() => navigate("/", { replace: true })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
hasTelegram && (
|
<>
|
||||||
<div className="card">
|
{hasPassword && <PasswordAuthCard onDone={() => navigate("/", { replace: true })} />}
|
||||||
<h3>Вход через Telegram</h3>
|
{hasTelegram && (
|
||||||
{config?.telegram_bot_username ? (
|
<div className="card">
|
||||||
<TelegramLoginButton
|
<h3>Вход через Telegram</h3>
|
||||||
botUsername={config.telegram_bot_username}
|
{config?.telegram_bot_username ? (
|
||||||
onAuth={(u) => onTelegram(u as unknown as Record<string, unknown>)}
|
<TelegramLoginButton
|
||||||
/>
|
botUsername={config.telegram_bot_username}
|
||||||
) : (
|
onAuth={(u) => onTelegram(u as unknown as Record<string, unknown>)}
|
||||||
<p className="muted small">
|
/>
|
||||||
Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN
|
) : (
|
||||||
в .env (бот от @BotFather + /setdomain на ваш домен).
|
<p className="muted small">
|
||||||
|
Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN
|
||||||
|
в .env (бот от @BotFather + /setdomain на ваш домен).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="muted small" style={{ marginBottom: 0 }}>
|
||||||
|
Аккаунт создан по логину и паролю? Сначала привяжите Telegram в профиле —
|
||||||
|
иначе вход через Telegram создаст новый аккаунт.
|
||||||
</p>
|
</p>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showStub && !pendingTg && <DevLogin onError={setError} />}
|
{showStub && !pendingTg && <DevLogin onError={setError} />}
|
||||||
@@ -98,6 +108,106 @@ export function LoginPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Основной вход: логин (ник) и пароль, с переключением на регистрацию.
|
||||||
|
function PasswordAuthCard({ onDone }: { onDone: () => void }) {
|
||||||
|
const login = usePasswordLogin();
|
||||||
|
const register = useRegister();
|
||||||
|
const [mode, setMode] = useState<"login" | "register">("login");
|
||||||
|
const [nickname, setNickname] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [repeat, setRepeat] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const isRegister = mode === "register";
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const nick = nickname.trim();
|
||||||
|
if (!nick || !password) {
|
||||||
|
setError("Введите никнейм и пароль.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isRegister && password !== repeat) {
|
||||||
|
setError("Пароли не совпадают.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
if (isRegister) await register.mutateAsync({ nickname: nick, password });
|
||||||
|
else await login.mutateAsync({ nickname: nick, password });
|
||||||
|
onDone();
|
||||||
|
} catch (err) {
|
||||||
|
const fallback = isRegister ? "Не удалось зарегистрироваться" : "Не удалось войти";
|
||||||
|
setError(err instanceof ApiError ? err.message : fallback);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const switchMode = () => {
|
||||||
|
setMode(isRegister ? "login" : "register");
|
||||||
|
setRepeat("");
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="card" onSubmit={submit}>
|
||||||
|
<h3>{isRegister ? "Регистрация" : "Вход"}</h3>
|
||||||
|
<div className="field">
|
||||||
|
<label className="label" htmlFor="auth-nickname">
|
||||||
|
Никнейм
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="auth-nickname"
|
||||||
|
name="username"
|
||||||
|
autoComplete="username"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect="off"
|
||||||
|
spellCheck={false}
|
||||||
|
value={nickname}
|
||||||
|
onChange={(e) => setNickname(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label className="label" htmlFor="auth-password">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="auth-password"
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete={isRegister ? "new-password" : "current-password"}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
{isRegister && <div className="muted small">Не короче 8 символов.</div>}
|
||||||
|
</div>
|
||||||
|
{isRegister && (
|
||||||
|
<div className="field">
|
||||||
|
<label className="label" htmlFor="auth-repeat">
|
||||||
|
Повторите пароль
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="auth-repeat"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={repeat}
|
||||||
|
onChange={(e) => setRepeat(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p className="error-text">{error}</p>}
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary btn-block"
|
||||||
|
disabled={login.isPending || register.isPending}
|
||||||
|
>
|
||||||
|
{isRegister ? "Зарегистрироваться" : "Войти"}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-ghost btn-block small" style={{ marginTop: 8 }} onClick={switchMode}>
|
||||||
|
{isRegister ? "Уже есть аккаунт? Войти" : "Нет аккаунта? Зарегистрироваться"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Выбор ника при регистрации через Telegram, когда тег занят/некорректен.
|
// Выбор ника при регистрации через Telegram, когда тег занят/некорректен.
|
||||||
// Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново).
|
// Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново).
|
||||||
function TelegramNicknamePicker({
|
function TelegramNicknamePicker({
|
||||||
|
|||||||
@@ -165,10 +165,9 @@ export function MatchDetailPage() {
|
|||||||
expected_version: match.version,
|
expected_version: match.version,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.show("Партия завершена");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isStale(e)) {
|
if (isStale(e)) {
|
||||||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
toast.error("Партия изменилась на другом устройстве — обновлено");
|
||||||
refetch();
|
refetch();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -234,11 +233,10 @@ export function MatchDetailPage() {
|
|||||||
expected_version: match.version,
|
expected_version: match.version,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.show("Партия обновлена");
|
|
||||||
cancelEditing();
|
cancelEditing();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isStale(e)) {
|
if (isStale(e)) {
|
||||||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
toast.error("Партия изменилась на другом устройстве — обновлено");
|
||||||
refetch();
|
refetch();
|
||||||
cancelEditing();
|
cancelEditing();
|
||||||
return;
|
return;
|
||||||
@@ -257,11 +255,11 @@ export function MatchDetailPage() {
|
|||||||
navigate("/group", { replace: true });
|
navigate("/group", { replace: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isStale(e)) {
|
if (isStale(e)) {
|
||||||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
toast.error("Партия изменилась на другом устройстве — обновлено");
|
||||||
refetch();
|
refetch();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
toast.show(e instanceof ApiError ? e.message : "Не удалось выполнить");
|
toast.error(e instanceof ApiError ? e.message : "Не удалось выполнить");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export function PublicProfilePage() {
|
|||||||
await invite.mutateAsync({ groupId, nickname: data.nickname });
|
await invite.mutateAsync({ groupId, nickname: data.nickname });
|
||||||
toast.show("Приглашение отправлено");
|
toast.show("Приглашение отправлено");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Не удалось пригласить");
|
toast.error(e instanceof ApiError ? e.message : "Не удалось пригласить");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Pencil } from "lucide-react";
|
import { KeyRound, Pencil } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import { ApiError } from "../../api/client";
|
import { ApiError } from "../../api/client";
|
||||||
import { Spinner } from "../../components/Spinner";
|
import { Spinner } from "../../components/Spinner";
|
||||||
import { useToast } from "../../context/ToastContext";
|
import { useToast } from "../../context/ToastContext";
|
||||||
import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
|
import { useAdminSetPassword, useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
|
||||||
// DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV,
|
// DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV,
|
||||||
// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход).
|
// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход).
|
||||||
import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
|
import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
|
||||||
@@ -12,35 +12,56 @@ import { DevDeleteAccountButton } from "./DevDeleteAccountButton";
|
|||||||
export function AdminAccountsPage() {
|
export function AdminAccountsPage() {
|
||||||
const { data: users, isLoading } = useAdminUsers();
|
const { data: users, isLoading } = useAdminUsers();
|
||||||
const update = useAdminUpdateUser();
|
const update = useAdminUpdateUser();
|
||||||
|
const setPassword = useAdminSetPassword();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [editId, setEditId] = useState<number | null>(null);
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
const [editName, setEditName] = useState("");
|
const [editName, setEditName] = useState("");
|
||||||
|
// Новый пароль игроку (восстановление забытого): отдельный режим строки, как переименование.
|
||||||
|
const [passwordId, setPasswordId] = useState<number | null>(null);
|
||||||
|
const [newPassword, setNewPassword] = useState("");
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
|
|
||||||
const startEdit = (id: number, nickname: string) => {
|
const startEdit = (id: number, nickname: string) => {
|
||||||
|
setPasswordId(null);
|
||||||
setEditId(id);
|
setEditId(id);
|
||||||
setEditName(nickname);
|
setEditName(nickname);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const startPassword = (id: number) => {
|
||||||
|
setEditId(null);
|
||||||
|
setPasswordId(id);
|
||||||
|
setNewPassword("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const savePassword = async () => {
|
||||||
|
if (passwordId == null || !newPassword) return;
|
||||||
|
try {
|
||||||
|
await setPassword.mutateAsync({ userId: passwordId, password: newPassword });
|
||||||
|
toast.show("Пароль задан — сообщите его игроку");
|
||||||
|
setPasswordId(null);
|
||||||
|
setNewPassword("");
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof ApiError ? e.message : "Не удалось задать пароль");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const saveName = async () => {
|
const saveName = async () => {
|
||||||
const name = editName.trim();
|
const name = editName.trim();
|
||||||
if (editId == null || !name) return;
|
if (editId == null || !name) return;
|
||||||
try {
|
try {
|
||||||
await update.mutateAsync({ userId: editId, nickname: name });
|
await update.mutateAsync({ userId: editId, nickname: name });
|
||||||
toast.show("Ник обновлён");
|
|
||||||
setEditId(null);
|
setEditId(null);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Не удалось переименовать");
|
toast.error(e instanceof ApiError ? e.message : "Не удалось переименовать");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleActive = async (id: number, isActive: boolean) => {
|
const toggleActive = async (id: number, isActive: boolean) => {
|
||||||
try {
|
try {
|
||||||
await update.mutateAsync({ userId: id, is_active: !isActive });
|
await update.mutateAsync({ userId: id, is_active: !isActive });
|
||||||
toast.show("Сохранено");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
toast.error(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,12 +94,39 @@ export function AdminAccountsPage() {
|
|||||||
Отмена
|
Отмена
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : passwordId === u.id ? (
|
||||||
|
// Режим «задать пароль».
|
||||||
|
<div className="row">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder={`Новый пароль для ${u.nickname}`}
|
||||||
|
value={newPassword}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") savePassword();
|
||||||
|
if (e.key === "Escape") setPasswordId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary small"
|
||||||
|
disabled={setPassword.isPending || !newPassword}
|
||||||
|
onClick={savePassword}
|
||||||
|
>
|
||||||
|
Задать
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost small" onClick={() => setPasswordId(null)}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<div>
|
<div>
|
||||||
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">админ</span>}
|
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">админ</span>}
|
||||||
<div className="muted small">
|
<div className="muted small">
|
||||||
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
|
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
|
||||||
|
{u.role !== "admin" && !u.has_password && " · без пароля"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{u.role !== "admin" && (
|
{u.role !== "admin" && (
|
||||||
@@ -90,6 +138,13 @@ export function AdminAccountsPage() {
|
|||||||
>
|
>
|
||||||
<Pencil size={16} />
|
<Pencil size={16} />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost small"
|
||||||
|
title="Задать пароль"
|
||||||
|
onClick={() => startPassword(u.id)}
|
||||||
|
>
|
||||||
|
<KeyRound size={16} />
|
||||||
|
</button>
|
||||||
{import.meta.env.DEV && (
|
{import.meta.env.DEV && (
|
||||||
<DevDeleteAccountButton userId={u.id} nickname={u.nickname} />
|
<DevDeleteAccountButton userId={u.id} nickname={u.nickname} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -35,9 +35,8 @@ export function AdminAchievementsPage() {
|
|||||||
setName("");
|
setName("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
setCondition("");
|
setCondition("");
|
||||||
toast.show("Ачивка создана");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -100,9 +99,8 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
|
|||||||
body: { name: name.trim(), description, condition },
|
body: { name: name.trim(), description, condition },
|
||||||
});
|
});
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
toast.show("Сохранено");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -112,18 +110,16 @@ function AchievementCard({ ach }: { ach: AchievementRead }) {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
try {
|
try {
|
||||||
await uploadIcon.mutateAsync({ slug: ach.slug, file });
|
await uploadIcon.mutateAsync({ slug: ach.slug, file });
|
||||||
toast.show("Иконка обновлена");
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
toast.error(err instanceof ApiError ? err.message : "Не удалось загрузить");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const remove = async () => {
|
const remove = async () => {
|
||||||
try {
|
try {
|
||||||
await del.mutateAsync(ach.slug);
|
await del.mutateAsync(ach.slug);
|
||||||
toast.show("Удалено");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export function AdminFactionsPage() {
|
|||||||
await rename.mutateAsync({ factionId: id, name_ru: names[id] });
|
await rename.mutateAsync({ factionId: id, name_ru: names[id] });
|
||||||
toast.show("Название обновлено во всей системе");
|
toast.show("Название обновлено во всей системе");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка");
|
toast.error(e instanceof ApiError ? e.message : "Ошибка");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { MatchMedia } from "../../components/MatchMedia";
|
|||||||
import { Spinner } from "../../components/Spinner";
|
import { Spinner } from "../../components/Spinner";
|
||||||
import { Switch } from "../../components/Switch";
|
import { Switch } from "../../components/Switch";
|
||||||
import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
|
import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
|
||||||
import { useToast } from "../../context/ToastContext";
|
|
||||||
import {
|
import {
|
||||||
useAdminDeleteAttachment,
|
useAdminDeleteAttachment,
|
||||||
useAdminFactions,
|
useAdminFactions,
|
||||||
@@ -33,7 +32,6 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
const update = useAdminUpdateMatch();
|
const update = useAdminUpdateMatch();
|
||||||
const uploadAtt = useAdminUploadAttachment(matchId);
|
const uploadAtt = useAdminUploadAttachment(matchId);
|
||||||
const deleteAtt = useAdminDeleteAttachment(matchId);
|
const deleteAtt = useAdminDeleteAttachment(matchId);
|
||||||
const toast = useToast();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [rows, setRows] = useState<Row[]>([]);
|
const [rows, setRows] = useState<Row[]>([]);
|
||||||
@@ -83,7 +81,6 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
toast.show("Партия обновлена");
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
setError(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
||||||
|
|||||||
@@ -149,9 +149,8 @@ export function AdminRecordsPage() {
|
|||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
try {
|
try {
|
||||||
await del.mutateAsync(deleteTarget.id);
|
await del.mutateAsync(deleteTarget.id);
|
||||||
toast.show("Удалено");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
|
toast.error(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -57,9 +57,8 @@ export function DevDeleteAccountButton({
|
|||||||
setConfirmOpen(false);
|
setConfirmOpen(false);
|
||||||
try {
|
try {
|
||||||
await del.mutateAsync();
|
await del.mutateAsync();
|
||||||
toast.show("Аккаунт удалён");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.show(e instanceof ApiError ? e.message : "Не удалось удалить");
|
toast.error(e instanceof ApiError ? e.message : "Не удалось удалить");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -193,22 +193,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
.switch input:checked + .switch-track .switch-thumb { transform: translateX(18px); background: #fff; }
|
.switch input:checked + .switch-track .switch-thumb { transform: translateX(18px); background: #fff; }
|
||||||
.switch input:disabled + .switch-track { opacity: 0.5; }
|
.switch input:disabled + .switch-track { opacity: 0.5; }
|
||||||
|
|
||||||
.toast {
|
|
||||||
position: fixed;
|
|
||||||
left: 50%;
|
|
||||||
bottom: calc(var(--bottombar-height) + 16px);
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: var(--surface-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
color: var(--text);
|
|
||||||
padding: 10px 16px;
|
|
||||||
border-radius: 999px;
|
|
||||||
z-index: 100;
|
|
||||||
max-width: 90%;
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ─── Уведомления ─────────────────────────────────────────────────────────── */
|
/* ─── Уведомления ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
/* Зоны верхней панели: слева back/заголовок, по центру название, справа колокольчик */
|
/* Зоны верхней панели: слева back/заголовок, по центру название, справа колокольчик */
|
||||||
@@ -471,5 +455,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
|||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||||
}
|
}
|
||||||
|
.toast-top-error { border-left-color: var(--danger); }
|
||||||
.toast-top-title { font-weight: 700; }
|
.toast-top-title { font-weight: 700; }
|
||||||
.toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); }
|
.toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); }
|
||||||
|
|||||||
Reference in New Issue
Block a user