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 ( + + ); +}