Files
mule-image/backend/app/routers/admin.py
dtoro 348e9c3585 feat: multi-user auth with per-user media isolation
Introduce username/password authentication with admin and user roles.
Each user gets their own media directory under /photos/{username}/ with
isolated photos, folders, heaps, and tags. Admins manage users and
observe the full library from a dedicated Settings page.

Backend:
- User model with bcrypt passwords and JWT access/refresh tokens
- Auth router (login, refresh, setup, change-password, status)
- Admin router (user CRUD with last-admin protection)
- user_id FK added to photos, folders, source_roots, heaps, tags
- All data routers scoped by authenticated user
- Scanner inherits user_id from source root owner
- Thumbnails stored under user-prefixed paths for isolation
- Library endpoints accept ?scope=global for admin cross-user view
- Alembic migration 0009 with data migration for existing installs
- Defensive bootstrap.py handles fresh vs existing DB startup

Frontend:
- AuthContext with token lifecycle, auto-refresh, login/logout
- Login page, first-run setup page, auth gate in App.tsx
- Bearer token interceptor on all API requests
- User identity + logout in left sidebar
- Admin-only Settings page with Library Management and Users tabs
- UserManagement panel (add, edit role, reset password, deactivate)
- Settings shows global stats across all users for admin
- Filter bar, right sidebar, keyboard hints hidden on settings page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 21:47:43 +02:00

261 lines
8.5 KiB
Python

"""
Admin router — user management and app configuration.
All endpoints require admin role.
"""
import os
import logging
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select, func as sa_func
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth import hash_password
from app.database import get_db
from app.dependencies import require_admin
from app.models.user import User
from app.models.photos import Photo
from app.models.folders import SourceRoot
from app.config import settings
logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Schemas
# ---------------------------------------------------------------------------
class CreateUserRequest(BaseModel):
username: str
password: str
role: str = "user" # 'admin' | 'user'
class UpdateUserRequest(BaseModel):
role: Optional[str] = None
is_active: Optional[bool] = None
new_password: Optional[str] = None
class UserDetailResponse(BaseModel):
id: str
username: str
email: Optional[str]
role: str
is_active: bool
media_path: str
created_at: Optional[str]
photo_count: int = 0
class UserListResponse(BaseModel):
users: List[UserDetailResponse]
total: int
# ---------------------------------------------------------------------------
# User CRUD
# ---------------------------------------------------------------------------
@router.get("/users", response_model=UserListResponse)
async def list_users(
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""List all users with their photo counts."""
result = await db.execute(select(User).order_by(User.created_at))
users = result.scalars().all()
user_list = []
for u in users:
count_result = await db.execute(
select(sa_func.count(Photo.id)).where(Photo.user_id == u.id)
)
photo_count = count_result.scalar() or 0
user_list.append(UserDetailResponse(
id=u.id,
username=u.username,
email=u.email,
role=u.role,
is_active=u.is_active,
media_path=u.media_path,
created_at=u.created_at.isoformat() if u.created_at else None,
photo_count=photo_count,
))
return UserListResponse(users=user_list, total=len(user_list))
@router.post("/users", status_code=201, response_model=UserDetailResponse)
async def create_user(
body: CreateUserRequest,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Create a new user. Creates their media directory and source root."""
if body.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'")
if len(body.username.strip()) < 2:
raise HTTPException(status_code=400, detail="Username must be at least 2 characters")
if len(body.password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
# Check for duplicate username
existing = await db.execute(
select(User).where(User.username == body.username.strip())
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(status_code=409, detail="Username already taken")
media_path = os.path.join(settings.photo_dirs, body.username.strip())
os.makedirs(media_path, exist_ok=True)
user = User(
username=body.username.strip(),
hashed_password=hash_password(body.password),
role=body.role,
media_path=media_path,
)
db.add(user)
await db.flush() # get user.id before creating source root
source_root = SourceRoot(
name=f"{user.username}'s Library",
path=media_path,
user_id=user.id,
)
db.add(source_root)
await db.commit()
logger.info(f"Admin '{admin.username}' created user '{user.username}' (role={user.role})")
return UserDetailResponse(
id=user.id,
username=user.username,
email=user.email,
role=user.role,
is_active=user.is_active,
media_path=user.media_path,
created_at=user.created_at.isoformat() if user.created_at else None,
photo_count=0,
)
@router.get("/users/{user_id}", response_model=UserDetailResponse)
async def get_user(
user_id: str,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Get a single user's details."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
count_result = await db.execute(
select(sa_func.count(Photo.id)).where(Photo.user_id == user.id)
)
photo_count = count_result.scalar() or 0
return UserDetailResponse(
id=user.id,
username=user.username,
email=user.email,
role=user.role,
is_active=user.is_active,
media_path=user.media_path,
created_at=user.created_at.isoformat() if user.created_at else None,
photo_count=photo_count,
)
@router.patch("/users/{user_id}", response_model=UserDetailResponse)
async def update_user(
user_id: str,
body: UpdateUserRequest,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Update a user's role, active status, or password."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if body.role is not None:
if body.role not in ("admin", "user"):
raise HTTPException(status_code=400, detail="Role must be 'admin' or 'user'")
# Prevent demoting the last admin
if user.role == "admin" and body.role == "user":
admin_count = (await db.execute(
select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True)
)).scalar()
if admin_count <= 1:
raise HTTPException(status_code=400, detail="Cannot demote the last admin")
user.role = body.role
if body.is_active is not None:
# Prevent deactivating the last admin
if user.role == "admin" and not body.is_active:
admin_count = (await db.execute(
select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True)
)).scalar()
if admin_count <= 1:
raise HTTPException(status_code=400, detail="Cannot deactivate the last admin")
user.is_active = body.is_active
if body.new_password is not None:
if len(body.new_password) < 6:
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
user.hashed_password = hash_password(body.new_password)
await db.commit()
count_result = await db.execute(
select(sa_func.count(Photo.id)).where(Photo.user_id == user.id)
)
photo_count = count_result.scalar() or 0
return UserDetailResponse(
id=user.id,
username=user.username,
email=user.email,
role=user.role,
is_active=user.is_active,
media_path=user.media_path,
created_at=user.created_at.isoformat() if user.created_at else None,
photo_count=photo_count,
)
@router.delete("/users/{user_id}")
async def delete_user(
user_id: str,
admin: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
"""Soft-delete a user by deactivating them. Media is preserved."""
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="Cannot delete yourself")
# Prevent deleting the last admin
if user.role == "admin":
admin_count = (await db.execute(
select(sa_func.count(User.id)).where(User.role == "admin", User.is_active == True)
)).scalar()
if admin_count <= 1:
raise HTTPException(status_code=400, detail="Cannot delete the last admin")
user.is_active = False
await db.commit()
logger.info(f"Admin '{admin.username}' deactivated user '{user.username}'")
return {"status": "ok", "detail": f"User '{user.username}' deactivated"}