Добавление аутентификации логин/пароль (#24) #53

Merged
NotBigGhost merged 4 commits from issue-24-login-password into dev 2026-09-13 15:36:51 +03:00
22 changed files with 1599 additions and 57 deletions
+17 -9
View File
@@ -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`); прод-контейнер это значение **игнорирует** и всегда
+90
View File
@@ -0,0 +1,90 @@
"""Вход игрока по логину (нику) и паролю.
Прод-модуль: основной способ входа во всех окружениях. НЕ путать с 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
# Неверный текущий пароль при смене — на аккаунт.
_CURRENT_PASSWORD_LIMIT = 5
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()}"
limits = {pair_key: _PAIR_LIMIT, f"login-ip:{ip}": _IP_LIMIT}
login_throttle.check(limits)
try:
user = authenticate_player(session, nickname, password)
except InvalidCredentialsError:
login_throttle.fail(limits)
raise
login_throttle.reset(pair_key)
return user
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)
+2 -2
View File
@@ -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
+36
View File
@@ -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()
+70
View File
@@ -0,0 +1,70 @@
"""Ограничение неудачных попыток ввода пароля (защита от перебора).
Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn.
Перезапуск их обнуляет; для окна в 15 минут это приемлемо.
"""
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()
+37 -20
View File
@@ -68,24 +68,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 +109,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 возможность, вынесена в
+41 -3
View File
@@ -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
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,42 @@ 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 привязывается позже в профиле."""
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,
+53
View File
@@ -6,6 +6,8 @@ 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.auth.password import check_current_password
from app.auth.telegram import TelegramProvider
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 NotFoundError
from app.db.session import get_session from app.db.session import get_session
@@ -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,56 @@ 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,
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)
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(...),
+32 -1
View 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
+11
View File
@@ -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 (файл исключён из прод/тест-образа).
+68 -1
View File
@@ -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,65 @@ 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:
"""Записать новый пароль. Проверку текущего делает вызывающий (игрок — да, админ — нет)."""
validate_new_password(new_password)
user.password_hash = hash_password(new_password)
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):
+2
View File
@@ -18,6 +18,7 @@ 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.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
@@ -48,6 +49,7 @@ def client(engine):
yield s yield s
app.dependency_overrides[get_session] = _get_session app.dependency_overrides[get_session] = _get_session
login_throttle.clear() # счётчики неудачных входов глобальны для процесса
with TestClient(app) as c: with TestClient(app) as c:
yield c yield c
app.dependency_overrides.clear() app.dependency_overrides.clear()
+5 -4
View File
@@ -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):
+360
View File
@@ -0,0 +1,360 @@
"""Вход по логину (нику) и паролю: регистрация, вход, защита от перебора."""
from __future__ import annotations
from fastapi.testclient import TestClient
from sqlmodel import Session, select
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_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 _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
# ─── Привязка 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
+303
View File
@@ -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;
+4
View File
@@ -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>
); );
} }
@@ -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>
);
}
+15
View File
@@ -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 {
+46
View File
@@ -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 виджета).
+128
View File
@@ -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,
@@ -218,6 +223,9 @@ export function AccountPage() {
Сохранить Сохранить
</button> </button>
</div> </div>
<div className="muted small" style={{ marginTop: 6 }}>
Никнейм — это и логин для входа: после смены входите под новым.
</div>
</div> </div>
</div> </div>
@@ -256,9 +264,129 @@ 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);
toast.show("Telegram привязан");
} catch (err) {
toast.show(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>
);
}
+125 -15
View File
@@ -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({
+59 -2
View File
@@ -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,17 +12,40 @@ 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.show(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;
@@ -73,12 +96,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 +140,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} />
)} )}