- API и страницы профиля (редактирование, смена пароля, аватар), публичные карточки. - Сайдбар: блок пользователя, пункт Users для admin/root, исправлен порядок инициализации (показ admin-only после initAuthSession, currentUser). - GET /auth/me: ответ через ProfileMeResponse, исправлена валидация (is_founder bool). Команды и роли - Маршруты и UI команд; при редактировании роли: видимость Team, выбор команды в модалке, только команды с активным членством; API team_id в details/ update. - GET /api/v1/teams?membership=active для списка «своих» команд. - Форма роли: сегмент Team, панель выбора команды только при Team и не при с
378 lines
13 KiB
Python
378 lines
13 KiB
Python
"""Admin user management: list, detail, role (root), enable/disable, temporary ban, directory removal."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
|
from pydantic import BaseModel, Field
|
|
|
|
from app.deps.auth import get_current_admin_user_id, get_current_root_user_id
|
|
from app.db.pool import get_pool
|
|
from app.schemas.admin_users import AdminUserPatchRequest, AdminUserRoleUpdateRequest, TemporaryBanRequest
|
|
from app.services.founder_policy import (
|
|
account_group_from_role,
|
|
ensure_actor_may_modify_protected_user,
|
|
ensure_founder_role_not_changed,
|
|
)
|
|
|
|
router = APIRouter(prefix="/admin/users", tags=["admin-users"])
|
|
|
|
|
|
def _user_base_select() -> str:
|
|
return """
|
|
u.id::text as id,
|
|
u.username,
|
|
u.email,
|
|
u.full_name,
|
|
coalesce(u.role, 'user') as role,
|
|
coalesce(u.is_active, true) as is_active,
|
|
coalesce(u.is_founder, false) as is_founder,
|
|
u.deleted_at,
|
|
u.ban_until,
|
|
u.ban_reason,
|
|
u.banned_by_id::text as banned_by_id,
|
|
ban_by.username as banned_by_username,
|
|
u.created_at,
|
|
u.updated_at
|
|
"""
|
|
|
|
|
|
def _user_from_clause() -> str:
|
|
return "from users u left join users ban_by on ban_by.id = u.banned_by_id"
|
|
|
|
|
|
def _row_to_user_dict(row: Any) -> dict[str, Any]:
|
|
d = dict(row)
|
|
for k in ("created_at", "updated_at", "deleted_at", "ban_until"):
|
|
v = d.get(k)
|
|
if v is not None and hasattr(v, "isoformat"):
|
|
d[k] = v.isoformat()
|
|
d["account_group"] = account_group_from_role(d.get("role"))
|
|
d["is_founder"] = bool(d.get("is_founder"))
|
|
return d
|
|
|
|
|
|
async def _fetch_user_detail(conn, target_user_id: str) -> Any:
|
|
return await conn.fetchrow(
|
|
f"select {_user_base_select()} {_user_from_clause()} where u.id = $1::uuid",
|
|
target_user_id,
|
|
)
|
|
|
|
|
|
def _detail_response_for_user_row(row: Any) -> _AdminUserDetailResponse:
|
|
return _AdminUserDetailResponse(
|
|
user=_row_to_user_dict(row),
|
|
owned_clusters=[],
|
|
cluster_roles=[],
|
|
namespace_roles=[],
|
|
access_requests=[],
|
|
audit={"total_entries": 0, "recent": []},
|
|
)
|
|
|
|
|
|
class _AdminUserDetailResponse(BaseModel):
|
|
user: dict[str, Any]
|
|
owned_clusters: list[dict[str, Any]] = Field(default_factory=list)
|
|
cluster_roles: list[dict[str, Any]] = Field(default_factory=list)
|
|
namespace_roles: list[dict[str, Any]] = Field(default_factory=list)
|
|
access_requests: list[dict[str, Any]] = Field(default_factory=list)
|
|
audit: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
def _enrich_list_item(it: dict[str, Any]) -> None:
|
|
it["account_group"] = account_group_from_role(it.get("role"))
|
|
it["is_founder"] = bool(it.get("is_founder"))
|
|
|
|
|
|
@router.get("")
|
|
async def admin_list_users(
|
|
page: int = Query(1, ge=1),
|
|
per_page: int = Query(25, ge=1, le=200),
|
|
_: str = Depends(get_current_admin_user_id),
|
|
) -> dict[str, Any]:
|
|
offset = (page - 1) * per_page
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
total = int(await conn.fetchval("select count(*)::int from users") or 0)
|
|
rows = await conn.fetch(
|
|
f"""
|
|
select
|
|
{_user_base_select()},
|
|
0::int as owned_clusters_count,
|
|
0::int as cluster_role_bindings,
|
|
0::int as namespace_role_bindings,
|
|
0::int as access_requests_count,
|
|
0::int as audit_log_entries
|
|
{_user_from_clause()}
|
|
order by u.created_at desc
|
|
limit $1 offset $2
|
|
""",
|
|
per_page,
|
|
offset,
|
|
)
|
|
items = []
|
|
for r in rows:
|
|
it = dict(r)
|
|
for k in ("created_at", "deleted_at", "updated_at", "ban_until"):
|
|
v = it.get(k)
|
|
if v is not None and hasattr(v, "isoformat"):
|
|
it[k] = v.isoformat()
|
|
_enrich_list_item(it)
|
|
items.append(it)
|
|
total_pages = (total + per_page - 1) // per_page if total else 0
|
|
return {
|
|
"items": items,
|
|
"total": total,
|
|
"page": page,
|
|
"per_page": per_page,
|
|
"total_pages": total_pages,
|
|
}
|
|
|
|
|
|
@router.patch("/{target_user_id}/role", response_model=_AdminUserDetailResponse)
|
|
async def admin_patch_user_role(
|
|
target_user_id: str,
|
|
body: AdminUserRoleUpdateRequest,
|
|
actor_id: str = Depends(get_current_root_user_id),
|
|
) -> _AdminUserDetailResponse:
|
|
try:
|
|
UUID(target_user_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user id") from exc
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
row = await conn.fetchrow(
|
|
"select coalesce(is_founder, false) as is_founder from users where id=$1::uuid",
|
|
target_user_id,
|
|
)
|
|
if not row:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
await ensure_actor_may_modify_protected_user(conn, actor_id=actor_id, target_id=target_user_id)
|
|
await ensure_founder_role_not_changed(
|
|
target_is_founder=bool(row["is_founder"]),
|
|
new_role=body.role,
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
update users set role = $1, updated_at = now()
|
|
where id = $2::uuid
|
|
""",
|
|
body.role,
|
|
target_user_id,
|
|
)
|
|
row2 = await _fetch_user_detail(conn, target_user_id)
|
|
if not row2:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
return _detail_response_for_user_row(row2)
|
|
|
|
|
|
@router.post("/{target_user_id}/temporary-ban", response_model=_AdminUserDetailResponse)
|
|
async def admin_temporary_ban(
|
|
target_user_id: str,
|
|
body: TemporaryBanRequest,
|
|
chief_id: str = Depends(get_current_root_user_id),
|
|
) -> _AdminUserDetailResponse:
|
|
if target_user_id == chief_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="You cannot suspend your own account",
|
|
)
|
|
try:
|
|
UUID(target_user_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user id") from exc
|
|
until = body.until
|
|
now = datetime.now(tz=timezone.utc)
|
|
if until <= now:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Suspension end time must be in the future",
|
|
)
|
|
reason = body.reason.strip()
|
|
if not reason:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Reason is required")
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
await ensure_actor_may_modify_protected_user(conn, actor_id=chief_id, target_id=target_user_id)
|
|
deleted_at = await conn.fetchval("select deleted_at from users where id=$1::uuid", target_user_id)
|
|
if deleted_at is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="User was removed from directory",
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
update users
|
|
set
|
|
is_active = false,
|
|
ban_until = $1,
|
|
ban_reason = $2,
|
|
banned_by_id = $3::uuid,
|
|
updated_at = now()
|
|
where id = $4::uuid
|
|
""",
|
|
until,
|
|
reason,
|
|
chief_id,
|
|
target_user_id,
|
|
)
|
|
row = await _fetch_user_detail(conn, target_user_id)
|
|
if not row:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
return _detail_response_for_user_row(row)
|
|
|
|
|
|
@router.delete("/{target_user_id}/temporary-ban", response_model=_AdminUserDetailResponse)
|
|
async def admin_lift_temporary_ban(
|
|
target_user_id: str,
|
|
root_id: str = Depends(get_current_root_user_id),
|
|
) -> _AdminUserDetailResponse:
|
|
try:
|
|
UUID(target_user_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user id") from exc
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
await ensure_actor_may_modify_protected_user(conn, actor_id=root_id, target_id=target_user_id)
|
|
had_ban = await conn.fetchval(
|
|
"select ban_until from users where id=$1::uuid",
|
|
target_user_id,
|
|
)
|
|
if had_ban is None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="No active temporary suspension for this user",
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
update users
|
|
set
|
|
is_active = true,
|
|
ban_until = null,
|
|
ban_reason = null,
|
|
banned_by_id = null,
|
|
updated_at = now()
|
|
where id = $1::uuid
|
|
""",
|
|
target_user_id,
|
|
)
|
|
row = await _fetch_user_detail(conn, target_user_id)
|
|
if not row:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
return _detail_response_for_user_row(row)
|
|
|
|
|
|
@router.get("/{target_user_id}")
|
|
async def admin_get_user(
|
|
target_user_id: str,
|
|
_: str = Depends(get_current_admin_user_id),
|
|
) -> _AdminUserDetailResponse:
|
|
try:
|
|
UUID(target_user_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user id") from exc
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
row = await _fetch_user_detail(conn, target_user_id)
|
|
if not row:
|
|
return _AdminUserDetailResponse(user={})
|
|
return _detail_response_for_user_row(row)
|
|
|
|
|
|
@router.patch("/{target_user_id}")
|
|
async def admin_patch_user(
|
|
target_user_id: str,
|
|
body: AdminUserPatchRequest,
|
|
admin_id: str = Depends(get_current_root_user_id),
|
|
) -> _AdminUserDetailResponse:
|
|
if target_user_id == admin_id and body.is_active is False:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="You cannot disable your own account",
|
|
)
|
|
try:
|
|
UUID(target_user_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user id") from exc
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
meta = await conn.fetchrow(
|
|
"select deleted_at, coalesce(is_founder, false) as is_founder from users where id=$1::uuid",
|
|
target_user_id,
|
|
)
|
|
if not meta:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
await ensure_actor_may_modify_protected_user(conn, actor_id=admin_id, target_id=target_user_id)
|
|
if meta["deleted_at"] is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="User was removed from directory; restore is not supported via this endpoint",
|
|
)
|
|
if bool(meta["is_founder"]) and body.is_active is False:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="The founding account cannot be disabled",
|
|
)
|
|
await conn.execute(
|
|
"""
|
|
update users
|
|
set
|
|
is_active = $1,
|
|
ban_until = null,
|
|
ban_reason = null,
|
|
banned_by_id = null,
|
|
updated_at = now()
|
|
where id = $2::uuid
|
|
""",
|
|
body.is_active,
|
|
target_user_id,
|
|
)
|
|
row = await _fetch_user_detail(conn, target_user_id)
|
|
if not row:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
return _detail_response_for_user_row(row)
|
|
|
|
|
|
@router.delete("/{target_user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def admin_remove_user_from_directory(
|
|
target_user_id: str,
|
|
admin_id: str = Depends(get_current_admin_user_id),
|
|
) -> None:
|
|
if target_user_id == admin_id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="You cannot remove your own account",
|
|
)
|
|
try:
|
|
UUID(target_user_id)
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid user id") from exc
|
|
pool = get_pool()
|
|
async with pool.acquire() as conn:
|
|
await ensure_actor_may_modify_protected_user(conn, actor_id=admin_id, target_id=target_user_id)
|
|
row = await conn.fetchrow(
|
|
"select deleted_at from users where id=$1::uuid",
|
|
target_user_id,
|
|
)
|
|
if not row:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
if row["deleted_at"] is not None:
|
|
return None
|
|
await conn.execute(
|
|
"""
|
|
update users
|
|
set
|
|
deleted_at = now(),
|
|
is_active = false,
|
|
ban_until = null,
|
|
ban_reason = null,
|
|
banned_by_id = null,
|
|
updated_at = now()
|
|
where id = $1::uuid
|
|
""",
|
|
target_user_id,
|
|
)
|