переименовывание аккаунтов админом
This commit is contained in:
@@ -5,10 +5,16 @@ from typing import Any
|
|||||||
|
|
||||||
from sqlmodel import Session, select
|
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.security import verify_password
|
||||||
from app.core.timeutil import iso_utc
|
from app.core.timeutil import iso_utc
|
||||||
from app.models import AuditLog, Faction, Group, Match, MatchParticipant, User
|
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:
|
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:
|
if user is None:
|
||||||
raise NotFoundError("Пользователь не найден.")
|
raise NotFoundError("Пользователь не найден.")
|
||||||
if nickname is not None:
|
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:
|
if is_active is not None:
|
||||||
user.is_active = is_active
|
user.is_active = is_active
|
||||||
session.add(user)
|
session.add(user)
|
||||||
|
|||||||
@@ -106,6 +106,29 @@ def test_admin_edit_match_and_filters(client: TestClient, make_admin):
|
|||||||
assert bp["faction_name"] == "Хаоситы"
|
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):
|
def test_dev_delete_account_keeps_matches(client: TestClient, make_admin):
|
||||||
"""DEV-удаление: аккаунт удаляется, партии живут (игрок вычёркивается),
|
"""DEV-удаление: аккаунт удаляется, партии живут (игрок вычёркивается),
|
||||||
его созданные партии и группы переназначаются на админа."""
|
его созданные партии и группы переназначаются на админа."""
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { Pencil } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { ApiError } from "../../api/client";
|
||||||
import { Spinner } from "../../components/Spinner";
|
import { Spinner } from "../../components/Spinner";
|
||||||
import { useToast } from "../../context/ToastContext";
|
import { useToast } from "../../context/ToastContext";
|
||||||
import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
|
import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
|
||||||
@@ -9,15 +13,63 @@ export function AdminAccountsPage() {
|
|||||||
const { data: users, isLoading } = useAdminUsers();
|
const { data: users, isLoading } = useAdminUsers();
|
||||||
const update = useAdminUpdateUser();
|
const update = useAdminUpdateUser();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
const [editId, setEditId] = useState<number | null>(null);
|
||||||
|
const [editName, setEditName] = useState("");
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3>Аккаунты</h3>
|
<h3>Аккаунты</h3>
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
{(users ?? []).map((u) => (
|
{(users ?? []).map((u) => (
|
||||||
<div key={u.id} className="card" style={{ margin: 0 }}>
|
<div key={u.id} className="card" style={{ margin: 0 }}>
|
||||||
|
{editId === u.id ? (
|
||||||
|
// Режим переименования.
|
||||||
|
<div className="row">
|
||||||
|
<input
|
||||||
|
value={editName}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") saveName();
|
||||||
|
if (e.key === "Escape") setEditId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary small"
|
||||||
|
disabled={update.isPending || !editName.trim() || editName.trim() === u.nickname}
|
||||||
|
onClick={saveName}
|
||||||
|
>
|
||||||
|
ОК
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-ghost small" onClick={() => setEditId(null)}>
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<div>
|
<div>
|
||||||
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">admin</span>}
|
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">admin</span>}
|
||||||
@@ -26,24 +78,24 @@ export function AdminAccountsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{u.role !== "admin" && (
|
{u.role !== "admin" && (
|
||||||
<div className="row">
|
<div className="row" style={{ gap: 4 }}>
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost small"
|
||||||
|
title="Переименовать"
|
||||||
|
onClick={() => startEdit(u.id, u.nickname)}
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</button>
|
||||||
{import.meta.env.DEV && (
|
{import.meta.env.DEV && (
|
||||||
<DevDeleteAccountButton userId={u.id} nickname={u.nickname} />
|
<DevDeleteAccountButton userId={u.id} nickname={u.nickname} />
|
||||||
)}
|
)}
|
||||||
<button
|
<button className="btn small" onClick={() => toggleActive(u.id, u.is_active)}>
|
||||||
className="btn small"
|
|
||||||
onClick={async () => {
|
|
||||||
await update
|
|
||||||
.mutateAsync({ userId: u.id, is_active: !u.is_active })
|
|
||||||
.catch(() => {});
|
|
||||||
toast.show("Сохранено");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{u.is_active ? "Отключить" : "Включить"}
|
{u.is_active ? "Отключить" : "Включить"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user