From b302ec769a47aef229a602bb1dbacea93adb3f65 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 17 Jun 2026 16:43:43 +0300 Subject: [PATCH] =?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 && ( + + )} + +
+ )} +
+ )}
))}