From 865c967d5e628722a3e2d47a4dc44c1d16b0f2c9 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 17 Jun 2026 16:17:14 +0300 Subject: [PATCH 1/6] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=84=D0=BE=D1=80=D0=BC=D0=B0=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D1=80=D0=B8=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B0=D0=BA=D0=BA=D0=B0?= =?UTF-8?q?=D1=83=D0=BD=D1=82=D0=B0=20=D0=BD=D0=B0=20=D1=81=D0=BB=D1=83?= =?UTF-8?q?=D1=87=D0=B0=D0=B9,=20=D0=BA=D0=BE=D0=B3=D0=B4=D0=B0=20=D1=83?= =?UTF-8?q?=D0=B6=D0=B5=20=D1=81=D1=83=D1=89=D0=B5=D1=81=D1=82=D0=B2=D1=83?= =?UTF-8?q?=D0=B5=D1=82=20=D0=B0=D0=B0=D0=BA=D1=83=D0=BD=D1=82=20=D1=81=20?= =?UTF-8?q?=D0=BD=D0=B8=D0=BA=D0=BE=D0=BC,=20=D1=81=D0=BE=D0=B2=D0=BF?= =?UTF-8?q?=D0=B0=D0=B4=D0=B0=D1=8E=D1=89=D0=B8=D0=BC=20=D1=81=20=D0=A2?= =?UTF-8?q?=D0=93=20=D1=82=D0=B5=D0=B3=D0=BE=D0=BC=20=D1=80=D0=B5=D0=B3?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=80=D0=B8=D1=80=D1=83=D0=B5=D0=BC=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=B0=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/auth/login.py | 18 ++-- backend/app/core/errors.py | 14 ++++ backend/app/routers/auth.py | 42 +++++++++- backend/app/schemas/api.py | 6 ++ backend/app/services/user_service.py | 44 +++++++++- backend/tests/test_auth.py | 61 ++++++++++++++ frontend/src/api/schema.d.ts | 88 +++++++++++++++++++- frontend/src/hooks/auth.ts | 16 ++++ frontend/src/pages/LoginPage.tsx | 119 +++++++++++++++++++++++---- h -u origin dev | 29 +++++++ 10 files changed, 409 insertions(+), 28 deletions(-) create mode 100644 h -u origin dev diff --git a/backend/app/auth/login.py b/backend/app/auth/login.py index 9a54a6e..5601f08 100644 --- a/backend/app/auth/login.py +++ b/backend/app/auth/login.py @@ -15,22 +15,30 @@ from app.models import User from app.services import audit_service, user_service -def login_with_identity( - session: Session, response: Response, request: Request, identity: ExternalIdentity +def establish_session( + session: Session, response: Response, request: Request, user: User, provider: str ) -> User: - user = user_service.get_or_create_from_identity(session, identity) + """Открыть сессию уже найденному/созданному пользователю (cookie + аудит).""" if not user.is_active: raise ForbiddenError("Аккаунт отключён администратором.", code="ACCOUNT_DISABLED") - security.set_user_session(response, user.id, identity.provider) # type: ignore[arg-type] + security.set_user_session(response, user.id, provider) # type: ignore[arg-type] audit_service.record( session, actor_id=user.id, action="login", entity_type="user", entity_id=user.id, - payload={"provider": identity.provider}, + payload={"provider": provider}, ip=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"), ) session.commit() return user + + +def login_with_identity( + session: Session, response: Response, request: Request, identity: ExternalIdentity +) -> User: + """Вход с авто-созданием (dev-вход по нику): ник подбирается автоматически.""" + user = user_service.get_or_create_from_identity(session, identity) + return establish_session(session, response, request, user, identity.provider) diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index 5541f74..b82911a 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -94,6 +94,20 @@ class NicknameTakenError(ConflictError): super().__init__("Никнейм уже занят.") +class TelegramNicknameRequiredError(ConflictError): + """Первый вход через Telegram, но предлагаемый ник (тег) занят/некорректен — + фронт должен показать выбор ника и повторить регистрацию через /telegram/register. + `suggested` идёт в details для предзаполнения поля.""" + + code = "TELEGRAM_NICKNAME_REQUIRED" + + def __init__(self, suggested: str, message: str | None = None) -> None: + super().__init__( + message or f"Ник «{suggested}» уже занят — выберите другой.", + details={"suggested": suggested}, + ) + + class FactionNotAvailableError(ValidationError): code = "FACTION_NOT_AVAILABLE" diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index c7807dc..d3c0ba2 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -7,14 +7,16 @@ from __future__ import annotations from fastapi import APIRouter, Depends, Request, Response from sqlmodel import Session -from app.auth.login import login_with_identity +from app.auth.login import establish_session from app.auth.registry import enabled_methods from app.auth.telegram import TelegramProvider from app.core import security from app.core.config import settings +from app.core.errors import TelegramNicknameRequiredError from app.db.session import get_session from app.routers.users import build_me from app.schemas import api as s +from app.services import user_service router = APIRouter(prefix="/auth", tags=["auth"]) @@ -34,8 +36,44 @@ def telegram_login( response: Response, session: Session = Depends(get_session), ) -> s.MeRead: + """Вход/регистрация через Telegram. + + Возвращающийся пользователь (личность уже есть) — просто входит. Первый вход — + регистрация под тегом Telegram; если тег занят/некорректен, отдаём 409 + TELEGRAM_NICKNAME_REQUIRED, и фронт повторяет регистрацию через /telegram/register + с выбранным ником. + """ identity = TelegramProvider().authenticate(body.model_dump()) - user = login_with_identity(session, response, request, identity) + user = user_service.find_by_identity(session, identity) + if user is None: + suggested = (identity.suggested_nickname or "").strip() + if not user_service.nickname_format_ok(suggested) or not user_service.nickname_available( + session, suggested + ): + raise TelegramNicknameRequiredError(suggested) + user = user_service.register_from_identity(session, identity, suggested) + establish_session(session, response, request, user, identity.provider) + return build_me(session, user) + + +@router.post("/telegram/register", response_model=s.MeRead) +def telegram_register( + body: s.TelegramRegister, + request: Request, + response: Response, + session: Session = Depends(get_session), +) -> s.MeRead: + """Завершение регистрации через Telegram с выбранным ником. + + Подпись виджета проверяется заново (поле nickname в HMAC не входит), поэтому личность + доверенная — подделать чужой telegram_id нельзя. Если пользователь уже успел + зарегистрироваться (гонка) — просто входим. + """ + identity = TelegramProvider().authenticate(body.model_dump(exclude={"nickname"})) + user = user_service.find_by_identity(session, identity) + if user is None: + user = user_service.register_from_identity(session, identity, body.nickname) + establish_session(session, response, request, user, identity.provider) return build_me(session, user) diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index bd90d8c..b8fe7b8 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -29,6 +29,12 @@ class TelegramAuthPayload(BaseModel): photo_url: str | None = None +class TelegramRegister(TelegramAuthPayload): + # Регистрация через Telegram с явно выбранным ником (когда тег занят/некорректен). + # Подпись виджета проверяется по тем же полям; nickname в HMAC не входит. + nickname: str + + class DevLogin(BaseModel): nickname: str diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 9295eb5..7696b63 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -25,6 +25,11 @@ def nickname_available(session: Session, nickname: str, exclude_user_id: int | N return existing is None or existing.id == exclude_user_id +def nickname_format_ok(nickname: str) -> bool: + """Ник подходит по формату (2–64, буквы/цифры/пробел/.-_).""" + return bool(_NICK_RE.match((nickname or "").strip())) + + def _unique_nickname(session: Session, base: str) -> str: base = (base or "Игрок").strip()[:60] or "Игрок" candidate = base @@ -35,18 +40,24 @@ def _unique_nickname(session: Session, base: str) -> str: return candidate -def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User: +def find_by_identity(session: Session, identity: ExternalIdentity) -> User | None: + """Существующий пользователь по внешней личности (provider+external_id). + + Для Telegram external_id — это НЕИЗМЕНЯЕМЫЙ числовой id аккаунта (не @тег), поэтому + вход с любого устройства/после смены тега ведёт к одному и тому же пользователю. + """ link = session.exec( select(AuthIdentity).where( AuthIdentity.provider == identity.provider, AuthIdentity.external_id == identity.external_id, ) ).first() - if link is not None: - return get_user(session, link.user_id) + return get_user(session, link.user_id) if link is not None else None + +def _create_from_identity(session: Session, identity: ExternalIdentity, nickname: str) -> User: user = User( - nickname=_unique_nickname(session, identity.suggested_nickname or identity.external_id), + nickname=nickname, role="player", auth_provider=identity.provider, telegram_id=identity.telegram_id, @@ -65,6 +76,31 @@ def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> return user +def get_or_create_from_identity(session: Session, identity: ExternalIdentity) -> User: + """Найти по личности или создать, авто-подбирая уникальный ник (dev-вход по нику).""" + user = find_by_identity(session, identity) + if user is not None: + return user + nickname = _unique_nickname(session, identity.suggested_nickname or identity.external_id) + return _create_from_identity(session, identity, nickname) + + +def register_from_identity( + session: Session, identity: ExternalIdentity, nickname: str +) -> User: + """Создать пользователя с ЯВНО выбранным ником (регистрация через Telegram). + + В отличие от get_or_create — ник не подбирается автоматически: занятый/неподходящий + ник вызывает ошибку, чтобы пользователь выбрал другой. + """ + nickname = (nickname or "").strip() + if not nickname_format_ok(nickname): + raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_") + if not nickname_available(session, nickname): + raise NicknameTakenError() + return _create_from_identity(session, identity, nickname) + + def update_nickname(session: Session, user: User, new_nickname: str) -> User: new_nickname = (new_nickname or "").strip() if not _NICK_RE.match(new_nickname): diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 94f9fca..810ad4f 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -79,3 +79,64 @@ def test_telegram_login_bad_signature_rejected(client: TestClient, monkeypatch): r = client.post("/api/auth/telegram", json=payload) assert r.status_code == 401 _ = csrf_headers # (для единообразия импорта) + + +def test_telegram_same_id_no_duplicate_account(client: TestClient, monkeypatch): + """Вход с двух устройств / после смены тега — один аккаунт (ключ = числовой id).""" + from app.core.config import settings + + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + r1 = client.post("/api/auth/telegram", json=_telegram_payload("TEST_BOT_TOKEN", username="firsttag")) + assert r1.status_code == 200, r1.text + + client.cookies.clear() # «другое устройство» — без сессии + # Тот же telegram id (777), но другой тег и свежий auth_date. + r2 = client.post("/api/auth/telegram", json=_telegram_payload("TEST_BOT_TOKEN", username="changedtag")) + assert r2.status_code == 200, r2.text + assert r2.json()["id"] == r1.json()["id"] # тот же пользователь + assert r2.json()["nickname"] == r1.json()["nickname"] # ник не пересоздаётся + + +def test_telegram_register_nickname_collision_then_pick(client: TestClient, engine, monkeypatch): + """Первый вход через ТГ, тег совпал с чужим ником → просим выбрать ник → регистрируем.""" + from sqlmodel import Session + + from app.core.config import settings + from app.models import User + + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + with Session(engine) as s: # существующий игрок с ником = тегу из payload (ivan_tg) + s.add(User(nickname="ivan_tg", role="player", auth_provider="stub")) + s.commit() + + payload = _telegram_payload("TEST_BOT_TOKEN") # id=777, username=ivan_tg + r = client.post("/api/auth/telegram", json=payload) + assert r.status_code == 409, r.text + err = r.json()["error"] + assert err["code"] == "TELEGRAM_NICKNAME_REQUIRED" + assert err["details"]["suggested"] == "ivan_tg" + + # Повтор с выбранным свободным ником (та же подпись виджета). + r2 = client.post("/api/auth/telegram/register", json={**payload, "nickname": "Иван-новый"}) + assert r2.status_code == 200, r2.text + assert r2.json()["nickname"] == "Иван-новый" + assert r2.json()["telegram_id"] == 777 + + +def test_telegram_register_rejects_taken_nickname(client: TestClient, engine, monkeypatch): + """На шаге выбора ника всё ещё занятый ник отклоняется (NICKNAME_TAKEN).""" + from sqlmodel import Session + + from app.core.config import settings + from app.models import User + + monkeypatch.setattr(settings, "telegram_bot_token", "TEST_BOT_TOKEN") + with Session(engine) as s: + s.add(User(nickname="ivan_tg", role="player", auth_provider="stub")) + s.add(User(nickname="занятый", role="player", auth_provider="stub")) + s.commit() + + payload = _telegram_payload("TEST_BOT_TOKEN") + r = client.post("/api/auth/telegram/register", json={**payload, "nickname": "занятый"}) + assert r.status_code == 409, r.text + assert r.json()["error"]["code"] == "NICKNAME_TAKEN" diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index d26fd50..be9e2a6 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -30,7 +30,15 @@ export interface paths { }; get?: never; put?: never; - /** Telegram Login */ + /** + * Telegram Login + * @description Вход/регистрация через Telegram. + * + * Возвращающийся пользователь (личность уже есть) — просто входит. Первый вход — + * регистрация под тегом Telegram; если тег занят/некорректен, отдаём 409 + * TELEGRAM_NICKNAME_REQUIRED, и фронт повторяет регистрацию через /telegram/register + * с выбранным ником. + */ post: operations["telegram_login_api_auth_telegram_post"]; delete?: never; options?: never; @@ -38,6 +46,30 @@ export interface paths { patch?: never; trace?: never; }; + "/api/auth/telegram/register": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Telegram Register + * @description Завершение регистрации через Telegram с выбранным ником. + * + * Подпись виджета проверяется заново (поле nickname в HMAC не входит), поэтому личность + * доверенная — подделать чужой telegram_id нельзя. Если пользователь уже успел + * зарегистрироваться (гонка) — просто входим. + */ + post: operations["telegram_register_api_auth_telegram_register_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/auth/logout": { parameters: { query?: never; @@ -1369,6 +1401,27 @@ export interface components { } & { [key: string]: unknown; }; + /** TelegramRegister */ + TelegramRegister: { + /** Id */ + id: number; + /** Auth Date */ + auth_date: number; + /** Hash */ + hash: string; + /** First Name */ + first_name?: string | null; + /** Last Name */ + last_name?: string | null; + /** Username */ + username?: string | null; + /** Photo Url */ + photo_url?: string | null; + /** Nickname */ + nickname: string; + } & { + [key: string]: unknown; + }; /** UserRead */ UserRead: { /** Id */ @@ -1459,6 +1512,39 @@ export interface operations { }; }; }; + telegram_register_api_auth_telegram_register_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TelegramRegister"]; + }; + }; + 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"]; + }; + }; + }; + }; logout_api_auth_logout_post: { parameters: { query?: never; diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts index 9948e11..383212d 100644 --- a/frontend/src/hooks/auth.ts +++ b/frontend/src/hooks/auth.ts @@ -44,6 +44,8 @@ export function useStubLogin() { } // Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC. +// При первом входе, если тег занят, бэкенд отдаёт 409 TELEGRAM_NICKNAME_REQUIRED — +// тогда вызывается useTelegramRegister с выбранным ником (тот же payload виджета). export function useTelegramLogin() { const qc = useQueryClient(); return useMutation({ @@ -56,6 +58,20 @@ export function useTelegramLogin() { }); } +// Регистрация через Telegram с выбранным ником (когда тег занят/некорректен). +// payload — те же данные виджета + nickname; подпись проверяется заново на сервере. +export function useTelegramRegister() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (payload: Record) => + unwrap(await api.POST("/api/auth/telegram/register", { body: payload as never })), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + qc.invalidateQueries(); + }, + }); +} + export function useCreateDevUser() { const qc = useQueryClient(); return useMutation({ diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index be1b8d0..2d94dfa 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -11,13 +11,21 @@ import { useMe, useStubLogin, useTelegramLogin, + useTelegramRegister, } from "../hooks/auth"; +// Данные виджета Telegram, отложенные до выбора ника (когда тег занят). +interface PendingTelegram { + payload: Record; + suggested: string; +} + export function LoginPage() { const { data: me, isLoading } = useMe(); const { data: config } = useAuthConfig(); const navigate = useNavigate(); const [error, setError] = useState(null); + const [pendingTg, setPendingTg] = useState(null); const methods = config?.methods ?? []; const hasTelegram = methods.includes("telegram"); @@ -36,6 +44,12 @@ export function LoginPage() { await telegram.mutateAsync(user); navigate("/", { replace: true }); } catch (e) { + // Тег занят/некорректен → просим выбрать ник и регистрируем тем же payload. + if (e instanceof ApiError && e.code === "TELEGRAM_NICKNAME_REQUIRED") { + const suggested = (e.details as { suggested?: string } | undefined)?.suggested ?? ""; + setPendingTg({ payload: user, suggested }); + return; + } setError(e instanceof ApiError ? e.message : "Не удалось войти через Telegram"); } }; @@ -48,24 +62,35 @@ export function LoginPage() {

Учёт партий

- {hasTelegram && ( -
-

Вход через Telegram

- {config?.telegram_bot_username ? ( - onTelegram(u as unknown as Record)} - /> - ) : ( -

- Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN - в .env (бот от @BotFather + /setdomain на ваш домен). -

- )} -
+ {pendingTg ? ( + { + setPendingTg(null); + setError(null); + }} + onDone={() => navigate("/", { replace: true })} + /> + ) : ( + hasTelegram && ( +
+

Вход через Telegram

+ {config?.telegram_bot_username ? ( + onTelegram(u as unknown as Record)} + /> + ) : ( +

+ Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN + в .env (бот от @BotFather + /setdomain на ваш домен). +

+ )} +
+ ) )} - {showStub && } + {showStub && !pendingTg && } {error &&

{error}

} @@ -83,6 +108,68 @@ export function LoginPage() { ); } +// Выбор ника при регистрации через Telegram, когда тег занят/некорректен. +// Повторяет регистрацию тем же payload виджета (подпись на сервере проверяется заново). +function TelegramNicknamePicker({ + pending, + onCancel, + onDone, +}: { + pending: PendingTelegram; + onCancel: () => void; + onDone: () => void; +}) { + const register = useTelegramRegister(); + const [nickname, setNickname] = useState(pending.suggested); + const [error, setError] = useState( + pending.suggested ? `Ник «${pending.suggested}» уже занят — выберите другой.` : null, + ); + + const submit = async () => { + const nick = nickname.trim(); + if (!nick) { + setError("Введите никнейм."); + return; + } + setError(null); + try { + await register.mutateAsync({ ...pending.payload, nickname: nick }); + onDone(); + } catch (e) { + setError(e instanceof ApiError ? e.message : "Не удалось зарегистрироваться"); + } + }; + + return ( +
+

Выберите никнейм

+

+ Ваш ник в Telegram уже занят другим игроком. Укажите другой никнейм для регистрации. +

+
+
+ setNickname(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") submit(); + }} + /> + +
+
+ {error &&

{error}

} + +
+ ); +} + // DEV-вход по нику. Вынесен в отдельный компонент; импортируется только в dev-ветке // LoginPage, поэтому в прод-сборке (import.meta.env.DEV=false) удаляется тришейкингом. function DevLogin({ onError }: { onError: (msg: string | null) => void }) { diff --git a/h -u origin dev b/h -u origin dev new file mode 100644 index 0000000..36b4302 --- /dev/null +++ b/h -u origin dev @@ -0,0 +1,29 @@ +commit 65bebf8b85fcbf921cbbc4f2599953a9fa3ca9c6 (HEAD -> dev) +Author: NotBigGhost +Date: Wed Jun 17 04:45:33 2026 +0300 + + Добавление ssh-ключа к контейнеру tunnel + +commit 7e8b748caf5d9f4ab3d2d7f76d6a4b850b9b1366 +Author: NotBigGhost +Date: Wed Jun 17 04:27:11 2026 +0300 + + Правки локального размещения дева, перенос тунеля в контейнер + +commit b04fbb2e171a7fe3376f3633dfee9e29e4e4bb90 +Author: NotBigGhost +Date: Tue Jun 16 19:15:54 2026 +0300 + + Завершена настройка домена для дева, теста и прода. Соединение через ssh-туннель + +commit 56b5d09a4dc5e6f5d06e32c2635c84a66597e71f (origin/main, main) +Author: NotBigGhost +Date: Tue Jun 16 17:41:06 2026 +0300 + + v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика + +commit 6ab74f01aaf1ac042a269c7660b3e564b93350c7 +Author: NotBigGhost +Date: Tue Jun 16 16:53:41 2026 +0300 + + first commit From 3291c67b2c0ffc0373ae8287c2df04b1d2f2cbe0 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 17 Jun 2026 16:34:29 +0300 Subject: [PATCH 2/6] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8=D1=8F=20?= =?UTF-8?q?=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B0=D0=BA?= =?UTF-8?q?=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=BE=D0=B2=20=D1=81=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=81=D0=BA=D0=B0=D0=B4=D0=BD=D1=8B=D0=BC=20=D1=83=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=B2=D1=81=D0=B5=D1=85=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B5?= =?UTF-8?q?=D0=B9=20=D0=B4=D0=BB=D1=8F=20=D0=B4=D0=B5=D0=B2=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 3 +- backend/app/main.py | 17 ++-- backend/app/routers/admin.py | 17 +--- backend/app/routers/dev_admin.py | 69 ++++++++++++++++ backend/app/services/admin_service.py | 8 +- backend/tests/test_admin_extra.py | 58 +++++++++++++ frontend/src/api/schema.d.ts | 82 +++++++++++-------- .../src/pages/admin/AdminAccountsPage.tsx | 30 ++++--- .../pages/admin/DevDeleteAccountButton.tsx | 60 ++++++++++++++ 9 files changed, 271 insertions(+), 73 deletions(-) create mode 100644 backend/app/routers/dev_admin.py create mode 100644 frontend/src/pages/admin/DevDeleteAccountButton.tsx diff --git a/.dockerignore b/.dockerignore index f4ce90b..54ede4e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -13,9 +13,10 @@ frontend/node_modules/ frontend/dist/ frontend/.vite/ -# DEV-вход (по нику) физически НЕ попадает в прод-образ +# DEV-код физически НЕ попадает в прод-образ (вход по нику, удаление аккаунтов) backend/app/auth/dev_stub.py backend/app/routers/dev_auth.py +backend/app/routers/dev_admin.py # Тесты и dev-манифест зависимостей в прод-образе не нужны (ставим из requirements.txt) backend/tests/ diff --git a/backend/app/main.py b/backend/app/main.py index af78510..8c2f2cb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -111,15 +111,16 @@ def create_app() -> FastAPI: for r in api_routers: app.include_router(r, prefix="/api") - # DEV-вход (по нику) — только в development и только если код физически есть - # (в test/prod-образе dev_auth/dev_stub исключены, импорт просто не выполнится). + # DEV-роутеры (вход по нику, жёсткое удаление аккаунтов) — только в development + # и только если код физически есть (в test/prod-образе dev_*-файлы исключены + # .dockerignore, импорт просто не выполнится). if settings.is_development: - try: - from app.routers import dev_auth - - app.include_router(dev_auth.router, prefix="/api") - except ImportError: - pass + for mod_name in ("dev_auth", "dev_admin"): + try: + mod = __import__(f"app.routers.{mod_name}", fromlist=["router"]) + app.include_router(mod.router, prefix="/api") + except ImportError: + pass @app.get("/api/health", tags=["meta"]) def health() -> dict: diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index ba64fbf..a615b1b 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -104,20 +104,9 @@ def update_user( ) -@router.delete("/users/{user_id}", response_model=s.OkResponse) -def delete_user( - user_id: int, - request: Request, - session: Session = Depends(get_session), - admin: User = Depends(get_current_admin), -) -> s.OkResponse: - admin_service.delete_user(session, user_id) - audit_service.record( - session, actor_id=admin.id, action="delete", entity_type="user", entity_id=user_id, - ip=request.client.host if request.client else None, - ) - session.commit() - return s.OkResponse() +# Удаление аккаунта — намеренно НЕ здесь: это dev-only возможность, вынесена в +# routers/dev_admin.py (исключён из прод/тест-образа). В проде аккаунт только +# отключается (PATCH is_active), удалять нельзя. # ─── Группы ────────────────────────────────────────────────────────────────── diff --git a/backend/app/routers/dev_admin.py b/backend/app/routers/dev_admin.py new file mode 100644 index 0000000..dd8130d --- /dev/null +++ b/backend/app/routers/dev_admin.py @@ -0,0 +1,69 @@ +"""DEV-ТОЛЬКО роутер: жёсткое удаление аккаунта игрока. + +Этот файл ФИЗИЧЕСКИ исключён из прод/тест-образа (.dockerignore), а роутер +подключается лишь когда APP_ENV == development (см. app/main.py). На фронте кнопка +удаления вырезается из прод-сборки тришейкингом (import.meta.env.DEV). Так +возможность удаления не попадает ни в прод, ни в тест — там аккаунт можно только +отключить (PATCH is_active). + +Семантика («вычёркивание из партий»): аккаунт удаляется, а партии сохраняются — +строки участия игрока удаляются (player_count партий НЕ пересчитывается, поэтому +их статистика может стать несогласованной — осознанный выбор). Созданные им партии +и группы в его владении переназначаются на админа (FK RESTRICT не даёт удалить +пользователя, а сами партии/группы мы сохраняем). +""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from sqlmodel import Session, select + +from app.auth.deps import get_current_admin +from app.core.errors import NotFoundError, ValidationError +from app.db.session import get_session +from app.models import Group, Match, MatchParticipant, User +from app.schemas import api as s +from app.services import audit_service + +router = APIRouter(prefix="/admin/dev", tags=["admin-dev"]) + + +@router.delete("/users/{user_id}", response_model=s.OkResponse) +def delete_user_hard( + user_id: int, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> s.OkResponse: + user = session.get(User, user_id) + if user is None: + raise NotFoundError("Пользователь не найден.") + if user.role == "admin": + raise ValidationError("Нельзя удалить администратора.") + nickname = user.nickname # сохраняем до удаления (для аудита) + + # 1. Вычёркиваем игрока из всех партий (его строки участия). + for part in session.exec( + select(MatchParticipant).where(MatchParticipant.user_id == user_id) + ).all(): + session.delete(part) + # 2. Партии и группы, где он создатель/владелец, переназначаем на админа. + for match in session.exec(select(Match).where(Match.created_by == user_id)).all(): + match.created_by = admin.id # type: ignore[assignment] + session.add(match) + for group in session.exec(select(Group).where(Group.owner_id == user_id)).all(): + group.owner_id = admin.id # type: ignore[assignment] + session.add(group) + session.flush() + # 3. group_members и auth_identity уйдут каскадом, audit_log.actor_id → NULL. + session.delete(user) + audit_service.record( + session, + actor_id=admin.id, + action="delete", + entity_type="user", + entity_id=user_id, + payload={"hard": True, "nickname": nickname}, + ip=request.client.host if request.client else None, + ) + session.commit() + return s.OkResponse() diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index 2c1c3d2..f0291f3 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -48,12 +48,8 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None, return user -def delete_user(session: Session, user_id: int) -> None: - user = session.get(User, user_id) - if user is None: - raise NotFoundError("Пользователь не найден.") - session.delete(user) - session.commit() +# Жёсткое удаление пользователя — dev-only, в services/admin_service нет намеренно: +# логика вынесена в routers/dev_admin.py (файл исключён из прод/тест-образа). # ─── Группы ────────────────────────────────────────────────────────────────── diff --git a/backend/tests/test_admin_extra.py b/backend/tests/test_admin_extra.py index e52ce61..8b2af1e 100644 --- a/backend/tests/test_admin_extra.py +++ b/backend/tests/test_admin_extra.py @@ -106,6 +106,64 @@ def test_admin_edit_match_and_filters(client: TestClient, make_admin): assert bp["faction_name"] == "Хаоситы" +def test_dev_delete_account_keeps_matches(client: TestClient, make_admin): + """DEV-удаление: аккаунт удаляется, партии живут (игрок вычёркивается), + его созданные партии и группы переназначаются на админа.""" + me = login(client, "Аня") # владелец группы и создатель партии + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + client.post("/api/auth/dev/users", json={"nickname": "Боря"}, headers=csrf_headers(client)) + b = client.post( + f"/api/groups/{gid}/members", json={"nickname": "Боря"}, headers=csrf_headers(client) + ).json()["user_id"] + factions = {f["code"]: f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()} + mid = create_finished_match( + client, + gid, + [ + {"user_id": me["id"], "faction_id": factions["orks"], "place": 1}, + {"user_id": b, "faction_id": factions["eldar"], "place": 2}, + ], + )["id"] + + admin_id = make_admin("admin", "secret123") + r = client.post( + "/api/admin/auth/login", + json={"username": "admin", "password": "secret123"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + + # Удаляем участника Борю → партия жива, его строка участия вычеркнута. + r = client.delete(f"/api/admin/dev/users/{b}", headers=csrf_headers(client)) + assert r.status_code == 200, r.text + assert all(u["id"] != b for u in client.get("/api/admin/users").json()) + detail = client.get(f"/api/admin/matches/{mid}").json() + assert [p["user_id"] for p in detail["participants"]] == [me["id"]] # Боря вычеркнут + assert detail["player_count"] == 2 # player_count НЕ пересчитывается (осознанно) + + # Удаляем создателя/владельца Аню → партия и группа сохраняются, переходят к админу. + r = client.delete(f"/api/admin/dev/users/{me['id']}", headers=csrf_headers(client)) + assert r.status_code == 200, r.text + assert client.get(f"/api/admin/matches/{mid}").json()["created_by"] == admin_id + grp = next(g for g in client.get("/api/admin/groups").json() if g["id"] == gid) + assert grp["owner_id"] == admin_id + + +def test_dev_delete_account_rejects_admin(client: TestClient, make_admin): + """Админа удалить нельзя.""" + admin_id = make_admin("admin", "secret123") + r = client.post( + "/api/admin/auth/login", + json={"username": "admin", "password": "secret123"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + r = client.delete(f"/api/admin/dev/users/{admin_id}", headers=csrf_headers(client)) + assert r.status_code == 422, r.text + + def test_admin_rename_faction_system_wide(client: TestClient, make_admin): me = login(client, "Кто-то") gid = client.post( diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index be9e2a6..affe258 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -518,8 +518,7 @@ export interface paths { get?: never; put?: never; post?: never; - /** Delete User */ - delete: operations["delete_user_api_admin_users__user_id__delete"]; + delete?: never; options?: never; head?: never; /** Update User */ @@ -682,6 +681,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/dev/users/{user_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete User Hard */ + delete: operations["delete_user_hard_api_admin_dev_users__user_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/health": { parameters: { query?: never; @@ -2479,37 +2495,6 @@ export interface operations { }; }; }; - delete_user_api_admin_users__user_id__delete: { - parameters: { - query?: never; - header?: never; - path: { - user_id: number; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Successful Response */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["OkResponse"]; - }; - }; - /** @description Validation Error */ - 422: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; update_user_api_admin_users__user_id__patch: { parameters: { query?: never; @@ -2903,6 +2888,37 @@ export interface operations { }; }; }; + delete_user_hard_api_admin_dev_users__user_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + user_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OkResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; health_api_health_get: { parameters: { query?: never; diff --git a/frontend/src/pages/admin/AdminAccountsPage.tsx b/frontend/src/pages/admin/AdminAccountsPage.tsx index e8bde0e..7e70df6 100644 --- a/frontend/src/pages/admin/AdminAccountsPage.tsx +++ b/frontend/src/pages/admin/AdminAccountsPage.tsx @@ -1,6 +1,9 @@ import { Spinner } from "../../components/Spinner"; import { useToast } from "../../context/ToastContext"; import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin"; +// DEV-ТОЛЬКО: удаление аккаунтов. Импорт используется лишь под import.meta.env.DEV, +// поэтому в прод/тест-сборке вырезается тришейкингом (как и dev-вход). +import { DevDeleteAccountButton } from "./DevDeleteAccountButton"; export function AdminAccountsPage() { const { data: users, isLoading } = useAdminUsers(); @@ -23,17 +26,22 @@ export function AdminAccountsPage() { {u.role !== "admin" && ( - +
+ {import.meta.env.DEV && ( + + )} + +
)} diff --git a/frontend/src/pages/admin/DevDeleteAccountButton.tsx b/frontend/src/pages/admin/DevDeleteAccountButton.tsx new file mode 100644 index 0000000..40b735e --- /dev/null +++ b/frontend/src/pages/admin/DevDeleteAccountButton.tsx @@ -0,0 +1,60 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +import { ApiError, api, unwrap } from "../../api/client"; +import { qk } from "../../api/queryKeys"; +import { useToast } from "../../context/ToastContext"; + +/** + * DEV-ТОЛЬКО кнопка жёсткого удаления аккаунта. + * + * Эндпоинт `DELETE /api/admin/dev/users/{id}` существует только в dev-сборке бэкенда + * (backend/app/routers/dev_admin.py, исключён из прод/тест-образа). Этот модуль + * рендерится лишь под `import.meta.env.DEV` в AdminAccountsPage, поэтому в прод-сборке + * он не используется и вырезается тришейкингом — в прод/тест удаление недоступно. + */ +export function DevDeleteAccountButton({ + userId, + nickname, +}: { + userId: number; + nickname: string; +}) { + const qc = useQueryClient(); + const toast = useToast(); + const del = useMutation({ + mutationFn: async () => + unwrap( + await api.DELETE("/api/admin/dev/users/{user_id}", { + params: { path: { user_id: userId } }, + }), + ), + onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }), + }); + + return ( + + ); +} From b302ec769a47aef229a602bb1dbacea93adb3f65 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 17 Jun 2026 16:43:43 +0300 Subject: [PATCH 3/6] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B5=D0=B8=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=BE=D0=B2=D1=8B=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D0=B0?= =?UTF-8?q?=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=BE=D0=B2=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/admin_service.py | 17 ++- backend/tests/test_admin_extra.py | 23 ++++ .../src/pages/admin/AdminAccountsPage.tsx | 100 +++++++++++++----- 3 files changed, 114 insertions(+), 26 deletions(-) diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index f0291f3..4616ea1 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -5,10 +5,16 @@ from typing import Any from sqlmodel import Session, select -from app.core.errors import InvalidCredentialsError, NotFoundError, ValidationError +from app.core.errors import ( + InvalidCredentialsError, + NicknameTakenError, + NotFoundError, + ValidationError, +) from app.core.security import verify_password from app.core.timeutil import iso_utc from app.models import AuditLog, Faction, Group, Match, MatchParticipant, User +from app.services import user_service def authenticate_admin(session: Session, username: str, password: str) -> User: @@ -39,7 +45,14 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None, if user is None: raise NotFoundError("Пользователь не найден.") if nickname is not None: - user.nickname = nickname.strip() + nickname = nickname.strip() + # Та же валидация, что и для самостоятельной смены ника игроком: + # корректный формат и уникальность (иначе занятый ник упал бы в 500). + if not user_service.nickname_format_ok(nickname): + raise ValidationError("Ник: 2–64 символа, буквы/цифры/пробел/.-_") + if not user_service.nickname_available(session, nickname, exclude_user_id=user_id): + raise NicknameTakenError() + user.nickname = nickname if is_active is not None: user.is_active = is_active session.add(user) diff --git a/backend/tests/test_admin_extra.py b/backend/tests/test_admin_extra.py index 8b2af1e..0e75643 100644 --- a/backend/tests/test_admin_extra.py +++ b/backend/tests/test_admin_extra.py @@ -106,6 +106,29 @@ def test_admin_edit_match_and_filters(client: TestClient, make_admin): assert bp["faction_name"] == "Хаоситы" +def test_admin_rename_account(client: TestClient, make_admin): + """Админ переименовывает аккаунт: валидируется формат и уникальность ника.""" + a = login(client, "Старое") + client.post("/api/auth/dev/users", json={"nickname": "Занятое"}, headers=csrf_headers(client)) + _admin_login(client, make_admin) + + r = client.patch( + f"/api/admin/users/{a['id']}", json={"nickname": "Новое"}, headers=csrf_headers(client) + ) + assert r.status_code == 200, r.text + assert r.json()["nickname"] == "Новое" + + # Занятый ник → 409, некорректный формат → 422. + r = client.patch( + f"/api/admin/users/{a['id']}", json={"nickname": "Занятое"}, headers=csrf_headers(client) + ) + assert r.status_code == 409 and r.json()["error"]["code"] == "NICKNAME_TAKEN", r.text + r = client.patch( + f"/api/admin/users/{a['id']}", json={"nickname": "x"}, headers=csrf_headers(client) + ) + assert r.status_code == 422, r.text + + def test_dev_delete_account_keeps_matches(client: TestClient, make_admin): """DEV-удаление: аккаунт удаляется, партии живут (игрок вычёркивается), его созданные партии и группы переназначаются на админа.""" diff --git a/frontend/src/pages/admin/AdminAccountsPage.tsx b/frontend/src/pages/admin/AdminAccountsPage.tsx index 7e70df6..3876bda 100644 --- a/frontend/src/pages/admin/AdminAccountsPage.tsx +++ b/frontend/src/pages/admin/AdminAccountsPage.tsx @@ -1,3 +1,7 @@ +import { Pencil } from "lucide-react"; +import { useState } from "react"; + +import { ApiError } from "../../api/client"; import { Spinner } from "../../components/Spinner"; import { useToast } from "../../context/ToastContext"; import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin"; @@ -9,41 +13,89 @@ export function AdminAccountsPage() { const { data: users, isLoading } = useAdminUsers(); const update = useAdminUpdateUser(); const toast = useToast(); + const [editId, setEditId] = useState(null); + const [editName, setEditName] = useState(""); if (isLoading) return ; + const startEdit = (id: number, nickname: string) => { + setEditId(id); + setEditName(nickname); + }; + + const saveName = async () => { + const name = editName.trim(); + if (editId == null || !name) return; + try { + await update.mutateAsync({ userId: editId, nickname: name }); + toast.show("Имя обновлено"); + setEditId(null); + } catch (e) { + toast.show(e instanceof ApiError ? e.message : "Не удалось переименовать"); + } + }; + + const toggleActive = async (id: number, isActive: boolean) => { + await update.mutateAsync({ userId: id, is_active: !isActive }).catch(() => {}); + toast.show("Сохранено"); + }; + return (

Аккаунты

{(users ?? []).map((u) => (
-
-
- {u.nickname} {u.role === "admin" && admin} -
- #{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"} -
+ {editId === u.id ? ( + // Режим переименования. +
+ setEditName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") saveName(); + if (e.key === "Escape") setEditId(null); + }} + /> + +
- {u.role !== "admin" && ( -
- {import.meta.env.DEV && ( - - )} - + ) : ( +
+
+ {u.nickname} {u.role === "admin" && admin} +
+ #{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"} +
- )} -
+ {u.role !== "admin" && ( +
+ + {import.meta.env.DEV && ( + + )} + +
+ )} +
+ )}
))}
From 96daddae05ad9fa84e392c19011e8ec1f6b3b64b Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 17 Jun 2026 17:27:24 +0300 Subject: [PATCH 4/6] =?UTF-8?q?=D0=9A=D0=B0=D1=81=D1=82=D0=BE=D0=BC=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8F=20(=D0=BE=20=D1=81=D0=B5=D0=B1=D0=B5,=20=D0=B0?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D0=B0=D1=80)=20=D0=B8=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=D0=BC=D0=BE=D1=82=D1=80=20=D0=BF=D1=80=D0=BE=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D0=B5=D0=B9=20=D0=B4=D1=80=D1=83=D0=B3=D0=B8=D1=85=20?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=BE=D0=B2=20+=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B0=D1=8F=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- backend/alembic/versions/0004_user_profile.py | 47 ++++ backend/app/models.py | 3 + backend/app/routers/users.py | 83 +++++- backend/app/schemas/api.py | 16 ++ backend/app/services/stats_service.py | 9 +- backend/app/services/user_service.py | 106 +++++++- backend/tests/test_profile.py | 129 +++++++++ frontend/src/api/queryKeys.ts | 1 + frontend/src/api/schema.d.ts | 249 ++++++++++++++++++ frontend/src/app/router.tsx | 4 + frontend/src/components/AppShell.tsx | 5 +- frontend/src/components/Avatar.tsx | 52 ++++ frontend/src/components/Leaderboard.tsx | 13 +- frontend/src/components/ProfileStatsCard.tsx | 10 +- frontend/src/domain/types.ts | 1 + frontend/src/hooks/auth.ts | 101 ++++++- frontend/src/hooks/stats.ts | 13 + frontend/src/pages/AccountPage.tsx | 114 +++++++- frontend/src/pages/HomePage.tsx | 16 +- frontend/src/pages/OverallStatsPage.tsx | 92 +++++++ frontend/src/pages/PublicProfilePage.tsx | 43 +++ frontend/src/styles/layout.css | 22 +- 22 files changed, 1106 insertions(+), 23 deletions(-) create mode 100644 backend/alembic/versions/0004_user_profile.py create mode 100644 backend/tests/test_profile.py create mode 100644 frontend/src/components/Avatar.tsx create mode 100644 frontend/src/pages/OverallStatsPage.tsx create mode 100644 frontend/src/pages/PublicProfilePage.tsx diff --git a/backend/alembic/versions/0004_user_profile.py b/backend/alembic/versions/0004_user_profile.py new file mode 100644 index 0000000..0a06257 --- /dev/null +++ b/backend/alembic/versions/0004_user_profile.py @@ -0,0 +1,47 @@ +"""Кастомизация профиля: о себе (bio) и аватар (avatar_path). + +Идемпотентна: на свежей БД (0001 создаёт схему из актуальных моделей через create_all) +столбцы уже есть → no-op; на существующей БД — добавляет столбцы. render_as_batch включён +в env.py (нужен для ALTER в SQLite). + +Revision ID: 0004_user_profile +Revises: 0003_match_lifecycle +Create Date: 2026-06-17 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0004_user_profile" +down_revision: Union[str, None] = "0003_match_lifecycle" +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")} + + add = [] + if "bio" not in cols: + add.append(sa.Column("bio", sa.Text(), nullable=True)) + if "avatar_path" not in cols: + add.append(sa.Column("avatar_path", sa.String(255), nullable=True)) + + if add: + with op.batch_alter_table("users") as b: + for col in add: + b.add_column(col) + + +def downgrade() -> None: + with op.batch_alter_table("users") as b: + for name in ("avatar_path", "bio"): + try: + b.drop_column(name) + except Exception: # noqa: BLE001 + pass diff --git a/backend/app/models.py b/backend/app/models.py index 1ae1415..30b964d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -80,6 +80,9 @@ class User(SQLModel, table=True): sa_column=Column(BigInteger, nullable=True, unique=True) ) nickname: str = Field(sa_column=Column(String(64), nullable=False, unique=True)) + bio: str | None = Field(default=None, sa_column=Column(Text, nullable=True)) + # Относительный путь файла аватара (под settings.upload_dir); наличие = есть аватар. + avatar_path: str | None = Field(default=None, sa_column=Column(String(255), nullable=True)) role: str = Field(default="player", sa_column=Column(String(16), nullable=False, index=True, server_default="player")) auth_provider: str = Field( default="stub", diff --git a/backend/app/routers/users.py b/backend/app/routers/users.py index c399f1e..a7f20b5 100644 --- a/backend/app/routers/users.py +++ b/backend/app/routers/users.py @@ -1,10 +1,12 @@ """Роутер текущего пользователя.""" from __future__ import annotations -from fastapi import APIRouter, Depends, Query, Request +from fastapi import APIRouter, Depends, File, Query, Request, UploadFile +from fastapi.responses import FileResponse from sqlmodel import Session from app.auth.deps import get_current_user +from app.core.errors import NotFoundError, ValidationError from app.db.session import get_session from app.models import User from app.schemas import api as s @@ -12,6 +14,8 @@ from app.services import audit_service, group_service, stats_service, user_servi router = APIRouter(prefix="/users", tags=["users"]) +_AVATAR_MAX_BYTES = 2 * 1024 * 1024 # 2 МБ + def build_me(session: Session, user: User) -> s.MeRead: groups = [ @@ -25,6 +29,8 @@ def build_me(session: Session, user: User) -> s.MeRead: auth_provider=user.auth_provider, telegram_id=user.telegram_id, active_group_id=user.active_group_id, + bio=user.bio, + avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] groups=groups, ) @@ -63,9 +69,84 @@ def update_me( auth_provider=user.auth_provider, telegram_id=user.telegram_id, active_group_id=user.active_group_id, + bio=user.bio, + avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] ) +@router.patch("/me/profile", response_model=s.MeRead) +def update_my_profile( + body: s.ProfileUpdate, + request: Request, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.MeRead: + user_service.update_bio(session, user, body.bio) + audit_service.record( + session, + actor_id=user.id, + action="update", + entity_type="user", + entity_id=user.id, + payload={"bio": True}, + ip=request.client.host if request.client else None, + ) + session.commit() + return build_me(session, user) + + +@router.put("/me/avatar", response_model=s.MeRead) +def upload_my_avatar( + file: UploadFile = File(...), + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.MeRead: + content = file.file.read(_AVATAR_MAX_BYTES + 1) + if len(content) > _AVATAR_MAX_BYTES: + raise ValidationError("Файл слишком большой (макс. 2 МБ).") + ext = user_service.sniff_image_ext(content) + if ext is None: + raise ValidationError("Поддерживаются только изображения PNG, JPEG или WebP.") + user_service.set_avatar(session, user, content, ext) + return build_me(session, user) + + +@router.delete("/me/avatar", response_model=s.MeRead) +def delete_my_avatar( + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.MeRead: + user_service.clear_avatar(session, user) + return build_me(session, user) + + +@router.get("/{user_id}/avatar") +def get_user_avatar( + user_id: int, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> FileResponse: + target = session.get(User, user_id) + path = user_service.avatar_abs_path(target.avatar_path) if target else None + if path is None or not path.exists(): + raise NotFoundError("Аватар не найден.") + ext = path.suffix.lstrip(".") + return FileResponse( + path, + media_type=user_service.avatar_media_type(ext), + headers={"Cache-Control": "public, max-age=3600"}, + ) + + +@router.get("/{user_id}/profile", response_model=s.PublicProfile) +def get_user_profile( + user_id: int, + session: Session = Depends(get_session), + _user: User = Depends(get_current_user), +) -> dict: + return user_service.public_profile(session, user_id) + + @router.get("/me/stats", response_model=s.ProfileStats) def my_stats( session: Session = Depends(get_session), diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index b8fe7b8..736e473 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -88,6 +88,8 @@ class UserRead(BaseModel): auth_provider: str telegram_id: int | None = None active_group_id: int | None = None + bio: str | None = None + avatar_url: str | None = None class MeRead(UserRead): @@ -98,6 +100,10 @@ class NicknameUpdate(BaseModel): nickname: str +class ProfileUpdate(BaseModel): + bio: str | None = None + + class ActiveGroupUpdate(BaseModel): group_id: int | None = None @@ -237,6 +243,7 @@ class LeaderboardEntry(OverallStats): user_id: int nickname: str rank: int | None = None + avatar_url: str | None = None class Leaderboard(BaseModel): @@ -273,6 +280,15 @@ class ProfileStats(BaseModel): recent_form: list[RecentFormItem] = [] +class PublicProfile(BaseModel): + # Профиль другого игрока (read-only): шапка + глобальная статистика. + user_id: int + nickname: str + bio: str | None = None + avatar_url: str | None = None + stats: ProfileStats + + class FactionMeta(BaseModel): faction_id: int code: str diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index e196723..5764e81 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -49,6 +49,10 @@ def _round(value: Any, ndigits: int) -> float | None: def _normalize(row: dict) -> dict: + # avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL. + avatar_url = None + if row.get("avatar_path"): + avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}" return { "user_id": row["user_id"], "nickname": row["nickname"], @@ -57,6 +61,7 @@ def _normalize(row: dict) -> dict: "win_rate": _round(row["win_rate"] or 0.0, 4), "avg_place": _round(row["avg_place"], 2), "score": _round(row["score"], 1), + "avatar_url": avatar_url, } @@ -65,6 +70,8 @@ def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]: sql = f""" {SCORED_CTE} SELECT u.id AS user_id, u.nickname AS nickname, + u.avatar_path AS avatar_path, + CAST(strftime('%s', u.updated_at) AS INTEGER) AS avatar_version, COUNT(*) AS games, SUM(s.is_win) AS wins, AVG(CAST(s.is_win AS FLOAT)) AS win_rate, @@ -73,7 +80,7 @@ def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]: FROM scored s JOIN users u ON u.id = s.user_id {where} - GROUP BY u.id, u.nickname + GROUP BY u.id, u.nickname, u.avatar_path, u.updated_at """ params = {"gid": group_id} if group_id is not None else {} result = session.execute(text(sql), params).mappings().all() diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index 7696b63..77907e2 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -1,15 +1,24 @@ -"""Пользователи: создание из внешней личности, ник, активная группа.""" +"""Пользователи: создание из внешней личности, ник, активная группа, профиль.""" from __future__ import annotations +import os import re +from datetime import datetime +from pathlib import Path from sqlmodel import Session, select from app.auth.provider import ExternalIdentity +from app.core.config import settings from app.core.errors import NicknameTakenError, NotFoundError, ValidationError from app.models import AuthIdentity, GroupMember, User _NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE) +_BIO_MAX = 500 + +# Аватары: файл на диске под settings.upload_dir/avatars, путь относительный. +_AVATAR_SUBDIR = "avatars" +_IMAGE_MEDIA = {"png": "image/png", "jpg": "image/jpeg", "webp": "image/webp"} def get_user(session: Session, user_id: int) -> User: @@ -128,3 +137,98 @@ def set_active_group(session: Session, user: User, group_id: int | None) -> User session.commit() session.refresh(user) return user + + +# ─── Профиль: «о себе», аватар ──────────────────────────────────────────────── + +def avatar_url_for(user_id: int, avatar_path: str | None, updated_at: datetime | None) -> str | None: + """URL аватара с кэш-бастингом по updated_at. None, если аватара нет. + + Один источник для build_me, публичного профиля и лидерборда — чтобы клиент + подтягивал новую картинку после смены (файл перезаписывается по тому же пути).""" + if not avatar_path: + return None + version = int(updated_at.timestamp()) if updated_at else 0 + return f"/api/users/{user_id}/avatar?v={version}" + + +def update_bio(session: Session, user: User, bio: str | None) -> User: + bio = (bio or "").strip() + if len(bio) > _BIO_MAX: + raise ValidationError(f"«О себе»: не более {_BIO_MAX} символов.") + user.bio = bio or None + session.add(user) + session.commit() + session.refresh(user) + return user + + +def sniff_image_ext(content: bytes) -> str | None: + """Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка.""" + if content.startswith(b"\x89PNG\r\n\x1a\n"): + return "png" + if content.startswith(b"\xff\xd8\xff"): + return "jpg" + if len(content) >= 12 and content[0:4] == b"RIFF" and content[8:12] == b"WEBP": + return "webp" + return None + + +def avatar_media_type(ext: str) -> str: + return _IMAGE_MEDIA.get(ext, "application/octet-stream") + + +def avatar_abs_path(avatar_path: str | None) -> Path | None: + """Абсолютный путь файла аватара (или None), для отдачи через FileResponse.""" + if not avatar_path: + return None + return Path(settings.upload_dir) / avatar_path + + +def set_avatar(session: Session, user: User, content: bytes, ext: str) -> User: + avatars_dir = Path(settings.upload_dir) / _AVATAR_SUBDIR + avatars_dir.mkdir(parents=True, exist_ok=True) + # Удаляем прежний файл (мог быть с другим расширением), затем пишем новый. + old = avatar_abs_path(user.avatar_path) + if old and old.exists(): + try: + old.unlink() + except OSError: + pass + rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}" + (Path(settings.upload_dir) / rel).write_bytes(content) + user.avatar_path = rel + session.add(user) + session.commit() + session.refresh(user) + return user + + +def clear_avatar(session: Session, user: User) -> User: + path = avatar_abs_path(user.avatar_path) + if path and path.exists(): + try: + path.unlink() + except OSError: + pass + user.avatar_path = None + session.add(user) + session.commit() + session.refresh(user) + return user + + +def public_profile(session: Session, user_id: int) -> dict: + """Публичный профиль игрока: ник/«о себе»/аватар + глобальная статистика.""" + from app.services import stats_service # ленивый импорт (избегаем цикла) + + user = session.get(User, user_id) + if user is None: + raise NotFoundError("Пользователь не найден.") + return { + "user_id": user.id, + "nickname": user.nickname, + "bio": user.bio, + "avatar_url": avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] + "stats": stats_service.profile_stats(session, user_id, group_id=None), + } diff --git a/backend/tests/test_profile.py b/backend/tests/test_profile.py new file mode 100644 index 0000000..3615eba --- /dev/null +++ b/backend/tests/test_profile.py @@ -0,0 +1,129 @@ +"""Профиль: «о себе» (bio), аватар (загрузка/отдача/удаление), публичный профиль.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import create_finished_match, csrf_headers, login + +# Минимальный «PNG»: достаточно сигнатуры — сервер не декодирует, только сниффит тип. +PNG = b"\x89PNG\r\n\x1a\n" + b"\x00" * 64 + + +def _use_tmp_uploads(monkeypatch, tmp_path) -> None: + from app.core.config import settings + + monkeypatch.setattr(settings, "dev_upload_dir", str(tmp_path)) + + +def _finished_match_for(client: TestClient, me: dict) -> int: + """Группа + второй игрок + одна завершённая партия (чтобы me попал в лидерборд).""" + exps = [e["id"] for e in client.get("/api/expansions").json()] + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client) + ).json()["id"] + client.post("/api/auth/dev/users", json={"nickname": "Соперник"}, headers=csrf_headers(client)) + p2 = client.post( + f"/api/groups/{gid}/members", json={"nickname": "Соперник"}, headers=csrf_headers(client) + ).json()["user_id"] + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + create_finished_match( + client, + gid, + [ + {"user_id": me["id"], "faction_id": fids[0], "place": 1}, + {"user_id": p2, "faction_id": fids[1], "place": 2}, + ], + ) + return gid + + +def test_update_bio(client: TestClient): + login(client, "Игрок") + r = client.patch("/api/users/me/profile", json={"bio": "Люблю орков"}, headers=csrf_headers(client)) + assert r.status_code == 200, r.text + assert r.json()["bio"] == "Люблю орков" + # Слишком длинное «о себе» → 422. + r = client.patch( + "/api/users/me/profile", json={"bio": "x" * 501}, headers=csrf_headers(client) + ) + assert r.status_code == 422, r.text + + +def test_avatar_upload_get_delete(client: TestClient, monkeypatch, tmp_path): + _use_tmp_uploads(monkeypatch, tmp_path) + me = login(client, "Аватарыч") + + # Загрузка валидного PNG → avatar_url выставлен. + r = client.put( + "/api/users/me/avatar", + files={"file": ("a.png", PNG, "image/png")}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + avatar_url = r.json()["avatar_url"] + assert avatar_url and "/avatar?v=" in avatar_url + + # Отдача файла. + g = client.get(f"/api/users/{me['id']}/avatar") + assert g.status_code == 200, g.text + assert g.content == PNG + assert g.headers["content-type"] == "image/png" + + # Удаление → avatar_url пропадает, файл больше не отдаётся. + r = client.delete("/api/users/me/avatar", headers=csrf_headers(client)) + assert r.status_code == 200 and r.json()["avatar_url"] is None, r.text + assert client.get(f"/api/users/{me['id']}/avatar").status_code == 404 + + +def test_avatar_rejects_non_image_and_oversize(client: TestClient, monkeypatch, tmp_path): + _use_tmp_uploads(monkeypatch, tmp_path) + login(client, "Игрок") + + r = client.put( + "/api/users/me/avatar", + files={"file": ("x.txt", b"not an image", "text/plain")}, + headers=csrf_headers(client), + ) + assert r.status_code == 422, r.text + + big = PNG + b"\x00" * (2 * 1024 * 1024 + 10) + r = client.put( + "/api/users/me/avatar", + files={"file": ("big.png", big, "image/png")}, + headers=csrf_headers(client), + ) + assert r.status_code == 422, r.text + + +def test_public_profile(client: TestClient): + me = login(client, "Публичный") + client.patch("/api/users/me/profile", json={"bio": "Привет"}, headers=csrf_headers(client)) + + r = client.get(f"/api/users/{me['id']}/profile") + assert r.status_code == 200, r.text + body = r.json() + assert body["nickname"] == "Публичный" + assert body["bio"] == "Привет" + assert body["avatar_url"] is None + assert "overall" in body["stats"] + + assert client.get("/api/users/999999/profile").status_code == 404 + + +def test_leaderboard_includes_avatar_url(client: TestClient, monkeypatch, tmp_path): + _use_tmp_uploads(monkeypatch, tmp_path) + me = login(client, "Топчик") + _finished_match_for(client, me) + + def entry_for(uid: int) -> dict: + board = client.get("/api/stats/leaderboard").json() + return next(e for e in board["entries"] + board["provisional"] if e["user_id"] == uid) + + assert entry_for(me["id"])["avatar_url"] is None # без аватара — null + + client.put( + "/api/users/me/avatar", + files={"file": ("a.png", PNG, "image/png")}, + headers=csrf_headers(client), + ) + assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index c950f57..a8a6d75 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -6,6 +6,7 @@ export const qk = { expansions: ["expansions"] as const, factions: ["factions"] as const, myStats: ["myStats"] as const, + publicProfile: (id: number) => ["publicProfile", id] as const, groups: ["groups"] as const, group: (id: number) => ["group", id] as const, groupFactions: (id: number) => ["group", id, "factions"] as const, diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index affe258..e0fd82e 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -105,6 +105,75 @@ export interface paths { patch: operations["update_me_api_users_me_patch"]; trace?: never; }; + "/api/users/me/profile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Update My Profile */ + patch: operations["update_my_profile_api_users_me_profile_patch"]; + trace?: never; + }; + "/api/users/me/avatar": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Upload My Avatar */ + put: operations["upload_my_avatar_api_users_me_avatar_put"]; + post?: never; + /** Delete My Avatar */ + delete: operations["delete_my_avatar_api_users_me_avatar_delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/{user_id}/avatar": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get User Avatar */ + get: operations["get_user_avatar_api_users__user_id__avatar_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/{user_id}/profile": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get User Profile */ + get: operations["get_user_profile_api_users__user_id__profile_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/users/me/stats": { parameters: { query?: never; @@ -843,6 +912,11 @@ export interface components { /** Telegram Bot Username */ telegram_bot_username?: string | null; }; + /** Body_upload_my_avatar_api_users_me_avatar_put */ + Body_upload_my_avatar_api_users_me_avatar_put: { + /** File */ + file: string; + }; /** DevLogin */ DevLogin: { /** Nickname */ @@ -1097,6 +1171,8 @@ export interface components { nickname: string; /** Rank */ rank?: number | null; + /** Avatar Url */ + avatar_url?: string | null; }; /** MatchCreate */ MatchCreate: { @@ -1272,6 +1348,10 @@ export interface components { telegram_id?: number | null; /** Active Group Id */ active_group_id?: number | null; + /** Bio */ + bio?: string | null; + /** Avatar Url */ + avatar_url?: string | null; /** * Groups * @default [] @@ -1363,6 +1443,23 @@ export interface components { */ recent_form: components["schemas"]["RecentFormItem"][]; }; + /** ProfileUpdate */ + ProfileUpdate: { + /** Bio */ + bio?: string | null; + }; + /** PublicProfile */ + PublicProfile: { + /** User Id */ + user_id: number; + /** Nickname */ + nickname: string; + /** Bio */ + bio?: string | null; + /** Avatar Url */ + avatar_url?: string | null; + stats: components["schemas"]["ProfileStats"]; + }; /** RandomizeRequest */ RandomizeRequest: { /** Group Id */ @@ -1452,6 +1549,10 @@ export interface components { telegram_id?: number | null; /** Active Group Id */ active_group_id?: number | null; + /** Bio */ + bio?: string | null; + /** Avatar Url */ + avatar_url?: string | null; }; /** ValidationError */ ValidationError: { @@ -1634,6 +1735,154 @@ export interface operations { }; }; }; + update_my_profile_api_users_me_profile_patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ProfileUpdate"]; + }; + }; + 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: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_upload_my_avatar_api_users_me_avatar_put"]; + }; + }; + 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"]; + }; + }; + }; + }; + delete_my_avatar_api_users_me_avatar_delete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeRead"]; + }; + }; + }; + }; + get_user_avatar_api_users__user_id__avatar_get: { + parameters: { + query?: never; + header?: never; + path: { + user_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_user_profile_api_users__user_id__profile_get: { + parameters: { + query?: never; + header?: never; + path: { + user_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PublicProfile"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; my_stats_api_users_me_stats_get: { parameters: { query?: never; diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 6fdabde..4da43bc 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -9,6 +9,8 @@ import { GroupSettingsPage } from "../pages/GroupSettingsPage"; import { HomePage } from "../pages/HomePage"; import { LoginPage } from "../pages/LoginPage"; import { MatchDetailPage } from "../pages/MatchDetailPage"; +import { OverallStatsPage } from "../pages/OverallStatsPage"; +import { PublicProfilePage } from "../pages/PublicProfilePage"; import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage"; import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage"; import { AdminLayout } from "../pages/admin/AdminLayout"; @@ -37,6 +39,8 @@ export const router = createBrowserRouter([ ), }, { path: "match/:matchId", element: }, + { path: "stats", element: }, + { path: "u/:userId", element: }, { path: "group", element: }, { path: "group/settings", diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 0dcea79..1fed4ed 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -8,12 +8,15 @@ const TITLES: Record = { "/group/settings": "Настройки группы", "/account": "Аккаунт", "/match/new": "Новая партия", + "/stats": "Общая статистика", }; export function AppShell() { const location = useLocation(); const navigate = useNavigate(); - const title = TITLES[location.pathname] ?? "Forbidden Stars"; + const title = + TITLES[location.pathname] ?? + (location.pathname.startsWith("/u/") ? "Профиль игрока" : "Forbidden Stars"); const isHome = location.pathname === "/"; return ( diff --git a/frontend/src/components/Avatar.tsx b/frontend/src/components/Avatar.tsx new file mode 100644 index 0000000..b18e583 --- /dev/null +++ b/frontend/src/components/Avatar.tsx @@ -0,0 +1,52 @@ +import type { CSSProperties } from "react"; + +/** + * Чисто презентационный аватар: картинка по url или инициал ника как заглушка. + * Используется и в своём, и в чужом профиле, и в списках (общий примитив без логики). + */ +export function Avatar({ + url, + nickname, + size = 40, +}: { + url?: string | null; + nickname: string; + size?: number; +}) { + const box: CSSProperties = { + width: size, + height: size, + borderRadius: "50%", + flexShrink: 0, + }; + if (url) { + return ( + {nickname} + ); + } + const initial = (nickname.trim()[0] ?? "?").toUpperCase(); + return ( +
+ {initial} +
+ ); +} diff --git a/frontend/src/components/Leaderboard.tsx b/frontend/src/components/Leaderboard.tsx index 66bea59..b1a803d 100644 --- a/frontend/src/components/Leaderboard.tsx +++ b/frontend/src/components/Leaderboard.tsx @@ -1,4 +1,7 @@ +import { Link } from "react-router-dom"; + import type { LeaderboardEntry } from "../domain/types"; +import { Avatar } from "./Avatar"; function pct(v: number | null | undefined): string { return v == null ? "—" : `${Math.round(v * 100)}%`; @@ -10,8 +13,16 @@ function Row({ entry }: { entry: LeaderboardEntry }) {
{entry.rank ?? "—"}
+
-
{entry.nickname}
+ e.stopPropagation()} + style={{ fontWeight: 600, color: "inherit" }} + > + {entry.nickname} +
Игр: {entry.games} Побед: {entry.wins} diff --git a/frontend/src/components/ProfileStatsCard.tsx b/frontend/src/components/ProfileStatsCard.tsx index 2f9022f..63f7a05 100644 --- a/frontend/src/components/ProfileStatsCard.tsx +++ b/frontend/src/components/ProfileStatsCard.tsx @@ -4,11 +4,17 @@ function pct(v: number | null | undefined): string { return v == null ? "—" : `${Math.round(v * 100)}%`; } -export function ProfileStatsCard({ stats }: { stats: ProfileStats }) { +export function ProfileStatsCard({ + stats, + title = "Моя статистика", +}: { + stats: ProfileStats; + title?: string; +}) { const o = stats.overall; return (
-

Моя статистика

+

{title}

Игр
{o.games} diff --git a/frontend/src/domain/types.ts b/frontend/src/domain/types.ts index 98e6b6f..1cc8557 100644 --- a/frontend/src/domain/types.ts +++ b/frontend/src/domain/types.ts @@ -15,6 +15,7 @@ export type MatchListItem = S["MatchListItem"]; export type Leaderboard = S["Leaderboard"]; export type LeaderboardEntry = S["LeaderboardEntry"]; export type ProfileStats = S["ProfileStats"]; +export type PublicProfile = S["PublicProfile"]; export type GroupStats = S["GroupStats"]; export type HomeResponse = S["HomeResponse"]; export type HomeInProgressMatch = S["HomeInProgressMatch"]; diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts index 383212d..aef11de 100644 --- a/frontend/src/hooks/auth.ts +++ b/frontend/src/hooks/auth.ts @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { api, unwrap } from "../api/client"; +import { ApiError, api, unwrap } from "../api/client"; import { qk } from "../api/queryKeys"; import type { AuthConfig, Me } from "../domain/types"; @@ -104,6 +104,105 @@ export function useUpdateNickname() { }); } +// «О себе». +export function useUpdateBio() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (bio: string): Promise => + unwrap(await api.PATCH("/api/users/me/profile", { body: { bio } })), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + qc.invalidateQueries({ queryKey: qk.home }); + }, + }); +} + +// Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным +// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную. +function readCsrfToken(): string | null { + const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/); + return m ? decodeURIComponent(m[1]) : null; +} + +async function resizeImage(file: File, max = 512): Promise { + try { + const dataUrl = await new Promise((res, rej) => { + const fr = new FileReader(); + fr.onload = () => res(fr.result as string); + fr.onerror = () => rej(fr.error); + fr.readAsDataURL(file); + }); + const img = await new Promise((res, rej) => { + const i = new Image(); + i.onload = () => res(i); + i.onerror = () => rej(new Error("image load failed")); + i.src = dataUrl; + }); + let { width, height } = img; + if (width > max || height > max) { + const scale = Math.min(max / width, max / height); + width = Math.round(width * scale); + height = Math.round(height * scale); + } + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) return file; + ctx.drawImage(img, 0, 0, width, height); + const blob = await new Promise((res) => + canvas.toBlob(res, "image/jpeg", 0.85), + ); + return blob ?? file; + } catch { + return file; // не вышло уменьшить — отправим как есть (сервер проверит тип/размер) + } +} + +export function useUploadAvatar() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (file: File): Promise => { + const blob = await resizeImage(file); + const form = new FormData(); + form.append("file", blob, "avatar.jpg"); + const csrf = readCsrfToken(); + const base = import.meta.env.VITE_API_BASE_URL || ""; + const r = await fetch(`${base}/api/users/me/avatar`, { + method: "PUT", + body: form, + credentials: "include", + headers: csrf ? { "X-CSRF-Token": csrf } : {}, + }); + if (!r.ok) { + let env: { code?: string; message?: string; details?: unknown } | undefined; + try { + env = ((await r.json()) as { error?: typeof env }).error; + } catch { + /* тело без JSON */ + } + throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status, env?.details); + } + return (await r.json()) as Me; + }, + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + qc.invalidateQueries(); + }, + }); +} + +export function useDeleteAvatar() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (): Promise => unwrap(await api.DELETE("/api/users/me/avatar")), + onSuccess: (me) => { + qc.setQueryData(qk.me, me); + qc.invalidateQueries(); + }, + }); +} + export function useSetActiveGroup() { const qc = useQueryClient(); return useMutation({ diff --git a/frontend/src/hooks/stats.ts b/frontend/src/hooks/stats.ts index 169bfe5..a02ed98 100644 --- a/frontend/src/hooks/stats.ts +++ b/frontend/src/hooks/stats.ts @@ -24,3 +24,16 @@ export function useMyStats() { queryFn: async () => unwrap(await api.GET("/api/users/me/stats")), }); } + +// Публичный профиль другого игрока (read-only). +export function usePublicProfile(userId: number) { + return useQuery({ + queryKey: qk.publicProfile(userId), + queryFn: async () => + unwrap( + await api.GET("/api/users/{user_id}/profile", { + params: { path: { user_id: userId } }, + }), + ), + }); +} diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index fcf2a90..bae0b26 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -1,26 +1,42 @@ -import { useState } from "react"; +import { useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { ApiError } from "../api/client"; +import { Avatar } from "../components/Avatar"; import { GroupSwitcher } from "../components/GroupSwitcher"; import { ProfileStatsCard } from "../components/ProfileStatsCard"; import { Spinner } from "../components/Spinner"; import { useToast } from "../context/ToastContext"; -import { useLogout, useMe, useUpdateNickname } from "../hooks/auth"; +import { + useDeleteAvatar, + useLogout, + useMe, + useUpdateBio, + useUpdateNickname, + useUploadAvatar, +} from "../hooks/auth"; import { useMyStats } from "../hooks/stats"; export function AccountPage() { const { data: me, isLoading } = useMe(); const { data: stats } = useMyStats(); const updateNick = useUpdateNickname(); + const updateBio = useUpdateBio(); + const uploadAvatar = useUploadAvatar(); + const deleteAvatar = useDeleteAvatar(); const logout = useLogout(); const toast = useToast(); const navigate = useNavigate(); + const fileRef = useRef(null); const [nickname, setNickname] = useState(""); + const [bio, setBio] = useState(null); if (isLoading || !me) return ; - const save = async () => { + // bio редактируется локально; до первого ввода берём значение из профиля. + const bioValue = bio ?? me.bio ?? ""; + + const saveNick = async () => { const nick = nickname.trim(); if (!nick) return; try { @@ -32,6 +48,36 @@ export function AccountPage() { } }; + const saveBio = async () => { + try { + await updateBio.mutateAsync(bioValue.trim()); + toast.show("Сохранено"); + } catch (e) { + toast.show(e instanceof ApiError ? e.message : "Ошибка"); + } + }; + + const onPickFile = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + e.target.value = ""; // позволяем выбрать тот же файл повторно + if (!file) return; + try { + await uploadAvatar.mutateAsync(file); + toast.show("Аватар обновлён"); + } catch (err) { + toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить"); + } + }; + + const removeAvatar = async () => { + try { + await deleteAvatar.mutateAsync(); + toast.show("Аватар удалён"); + } catch (e) { + toast.show(e instanceof ApiError ? e.message : "Ошибка"); + } + }; + const doLogout = async () => { await logout.mutateAsync().catch(() => {}); navigate("/login", { replace: true }); @@ -41,15 +87,64 @@ export function AccountPage() {

Профиль

-
- Никнейм - {me.nickname} +
+ +
+ {me.nickname} +
+ + {me.avatar_url && ( + + )} +
+ +
+ +
+ +