Backend: - /api/admin/users: GET list, POST create, PUT update, DELETE - Admin users can be added with email, display name, and password - Self-deletion and last-admin deletion are blocked - Admin role auto-assigned on creation Frontend: - AdminUsersTabComponent: list + add/delete admin users - AdminUserService: HTTP client for admin user CRUD - AdminUser interface - 'Admins' tab added to admin dashboard Tests: - 10 new tests for admin user management endpoints - 140 total tests pass (130 original + 10 new)
147 lines
5.0 KiB
Python
147 lines
5.0 KiB
Python
"""Admin user management: list, create, delete admin users."""
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import joinedload
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.auth import get_current_admin_user, hash_password
|
|
from app.database import get_session
|
|
from app.user_models import User, Role, user_roles
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
# ── Schemas ────────────────────────────────────────────────────
|
|
|
|
class AdminUserResponse(BaseModel):
|
|
id: int
|
|
email: str
|
|
display_name: str
|
|
auth_provider: str
|
|
is_admin: bool
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class CreateAdminRequest(BaseModel):
|
|
email: str = Field(..., pattern=r'^[^@\s]+@[^@>\s]+.[^@\s.]+$')
|
|
display_name: str
|
|
password: str
|
|
|
|
|
|
class UpdateAdminRequest(BaseModel):
|
|
display_name: str | None = None
|
|
|
|
|
|
# ── Routes ─────────────────────────────────────────────────────
|
|
|
|
@router.get("/users", response_model=list[AdminUserResponse])
|
|
async def list_admin_users(session: AsyncSession = Depends(get_session), current_user: User = Depends(get_current_admin_user)):
|
|
"""List all users with admin access."""
|
|
result = await session.execute(
|
|
select(User).options(joinedload(User.roles))
|
|
)
|
|
users = result.scalars().unique().all()
|
|
# Filter to only admin users
|
|
admins = [u for u in users if u.is_admin_effective]
|
|
return admins
|
|
|
|
|
|
@router.post("/users", response_model=AdminUserResponse, status_code=status.HTTP_201_CREATED)
|
|
async def create_admin_user(
|
|
payload: CreateAdminRequest,
|
|
session: AsyncSession = Depends(get_session),
|
|
current_user: User = Depends(get_current_admin_user),
|
|
):
|
|
"""Create a new admin user. Assigns the 'admin' role automatically."""
|
|
# Check if user already exists
|
|
existing = await session.execute(select(User).where(User.email == payload.email))
|
|
if existing.scalar_one_or_none():
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"User with email {payload.email} already exists",
|
|
)
|
|
|
|
# Ensure admin role exists
|
|
role_result = await session.execute(select(Role).where(Role.name == "admin"))
|
|
admin_role = role_result.scalar_one_or_none()
|
|
if admin_role is None:
|
|
admin_role = Role(name="admin", description="Full administrative access")
|
|
session.add(admin_role)
|
|
await session.flush()
|
|
|
|
# Create user with hashed password
|
|
user = User(
|
|
email=payload.email,
|
|
display_name=payload.display_name,
|
|
auth_provider="local",
|
|
password_hash=hash_password(payload.password),
|
|
is_admin=True,
|
|
)
|
|
user.roles.append(admin_role)
|
|
session.add(user)
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
return user
|
|
|
|
|
|
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def delete_admin_user(
|
|
user_id: int,
|
|
session: AsyncSession = Depends(get_session),
|
|
current_user: User = Depends(get_current_admin_user),
|
|
):
|
|
"""Delete an admin user. Cannot delete yourself. At least one admin must remain."""
|
|
# Prevent self-deletion
|
|
if user_id == current_user.id:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot delete your own account",
|
|
)
|
|
|
|
result = await session.execute(
|
|
select(User).options(joinedload(User.roles)).where(User.id == user_id)
|
|
)
|
|
user = result.unique().scalar_one_or_none()
|
|
if user is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
|
|
# Ensure at least one admin remains (don't count the user being deleted)
|
|
other_admins = await session.execute(
|
|
select(User).options(joinedload(User.roles)).where(User.id != user_id)
|
|
)
|
|
admin_count = sum(1 for u in other_admins.scalars().unique().all() if u.is_admin_effective)
|
|
if admin_count == 0:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail="Cannot delete the last admin user",
|
|
)
|
|
|
|
# Remove all roles
|
|
user.roles.clear()
|
|
await session.delete(user)
|
|
await session.commit()
|
|
|
|
|
|
@router.put("/users/{user_id}", response_model=AdminUserResponse)
|
|
async def update_admin_user(
|
|
user_id: int,
|
|
payload: UpdateAdminRequest,
|
|
session: AsyncSession = Depends(get_session),
|
|
current_user: User = Depends(get_current_admin_user),
|
|
):
|
|
"""Update admin user details."""
|
|
result = await session.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
if user is None:
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
|
|
|
if payload.display_name is not None:
|
|
user.display_name = payload.display_name
|
|
|
|
await session.commit()
|
|
await session.refresh(user)
|
|
return user
|