from __future__ import annotations import uuid from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from account_access import ( admin_reset_recipients, apply_identifier_edits, assert_address_free, assert_username_free, deliver_jobs, issue_password_reset, issue_verification, load_profile, normalize_email, normalize_username, profile_view, reset_message, verify_message, ) from auth import hash_password, require_admin_dep, require_auth from db import get_db from mailer import MailNotConfigured router = APIRouter(prefix="/api/users", tags=["users"]) class CreateUserRequest(BaseModel): email: str name: str = Field(min_length=1) password: str = Field(min_length=4) role: str = "user" username: str | None = None class PatchUserRequest(BaseModel): name: str | None = None role: str | None = None status: str | None = None password: str | None = None username: str | None = None email: str | None = None secondary_email: str | None = None class SelfPatchRequest(BaseModel): username: str | None = None email: str | None = None secondary_email: str | None = None password: str | None = None def _active_admin_count(conn, exclude_id: str | None = None) -> int: if exclude_id: row = conn.execute( "SELECT COUNT(*) AS n FROM profiles WHERE role = 'admin' AND status = 'active' AND id != ?", (exclude_id,), ).fetchone() else: row = conn.execute( "SELECT COUNT(*) AS n FROM profiles WHERE role = 'admin' AND status = 'active'" ).fetchone() return int(row["n"]) def _send_jobs(jobs: list[tuple[list[str], str, str]]) -> bool: if not jobs: return True try: deliver_jobs(jobs) except MailNotConfigured: return False return True @router.get("") def list_users(session: dict = Depends(require_admin_dep)): with get_db() as conn: rows = conn.execute("SELECT * FROM profiles ORDER BY created").fetchall() return [profile_view(dict(row)) for row in rows] @router.post("") def create_user(req: CreateUserRequest, session: dict = Depends(require_admin_dep)): if req.role not in {"user", "admin"}: raise HTTPException(400, "Rolle muss user oder admin sein") email = normalize_email(req.email) username = normalize_username(req.username) if req.username and req.username.strip() else None profile_id = str(uuid.uuid4()) jobs: list[tuple[list[str], str, str]] = [] try: with get_db() as conn: assert_address_free(conn, email, None) if username: assert_username_free(conn, username, None) conn.execute( """ INSERT INTO profiles (id, email, name, password_hash, role, status, tier_id, username) VALUES (?, ?, ?, ?, ?, 'active', 'local', ?) """, ( profile_id, email, req.name.strip(), hash_password(req.password), req.role, username, ), ) raw = issue_verification(conn, profile_id, "primary", email) if raw: subject, body = verify_message(raw) jobs.append(([email], subject, body)) prof = profile_view(load_profile(conn, profile_id)) except HTTPException: raise except Exception as exc: if "UNIQUE" in str(exc).upper(): raise HTTPException(409, "E-Mail ist bereits vergeben") from exc raise if not _send_jobs(jobs): prof = dict(prof or {}) prof["mail_sent"] = False return prof @router.patch("/me") def patch_me(req: SelfPatchRequest, session: dict = Depends(require_auth)): provided = req.model_fields_set jobs: list[tuple[list[str], str, str]] = [] with get_db() as conn: current = load_profile(conn, session["profile_id"]) if not current: raise HTTPException(401, "Profil nicht gefunden") if req.password: if len(req.password) < 4: raise HTTPException(400, "Passwort muss mind. 4 Zeichen haben") conn.execute( "UPDATE profiles SET password_hash = ? WHERE id = ?", (hash_password(req.password), current["id"]), ) jobs = apply_identifier_edits( conn, current, username=req.username, email=req.email, secondary_email=req.secondary_email, username_set="username" in provided, email_set="email" in provided, secondary_set="secondary_email" in provided, ) prof = profile_view(load_profile(conn, current["id"])) if not _send_jobs(jobs): raise HTTPException(503, "Die Adresse ist vorgemerkt, der Mailversand ist nicht konfiguriert.") return prof @router.patch("/{profile_id}") def patch_user(profile_id: str, req: PatchUserRequest, session: dict = Depends(require_admin_dep)): provided = req.model_fields_set jobs: list[tuple[list[str], str, str]] = [] with get_db() as conn: current = load_profile(conn, profile_id) if not current: raise HTTPException(404, "Nutzer nicht gefunden") name = req.name.strip() if req.name is not None else current["name"] role = req.role if req.role is not None else current["role"] status = req.status if req.status is not None else current["status"] if role not in {"user", "admin"}: raise HTTPException(400, "Rolle muss user oder admin sein") if status not in {"active", "disabled"}: raise HTTPException(400, "Status muss active oder disabled sein") becomes_non_admin = current["role"] == "admin" and (role != "admin" or status == "disabled") if becomes_non_admin and _active_admin_count(conn, exclude_id=profile_id) < 1: raise HTTPException(400, "Der letzte aktive Admin kann nicht entfernt werden") password_hash = current["password_hash"] if req.password: if len(req.password) < 4: raise HTTPException(400, "Passwort muss mind. 4 Zeichen haben") password_hash = hash_password(req.password) conn.execute( """ UPDATE profiles SET name = ?, role = ?, status = ?, password_hash = ? WHERE id = ? """, (name, role, status, password_hash, profile_id), ) if status == "disabled": conn.execute("DELETE FROM sessions WHERE profile_id = ?", (profile_id,)) current = load_profile(conn, profile_id) or current jobs = apply_identifier_edits( conn, current, username=req.username, email=req.email, secondary_email=req.secondary_email, username_set="username" in provided, email_set="email" in provided, secondary_set="secondary_email" in provided, ) prof = profile_view(load_profile(conn, profile_id)) if not _send_jobs(jobs): raise HTTPException(503, "Die Adresse ist vorgemerkt, der Mailversand ist nicht konfiguriert.") return prof @router.post("/{profile_id}/password-reset") def admin_password_reset(profile_id: str, session: dict = Depends(require_admin_dep)): if profile_id == session["profile_id"]: raise HTTPException(400, "Den eigenen Reset startest du über die Anmeldung") jobs: list[tuple[list[str], str, str]] = [] with get_db() as conn: profile = load_profile(conn, profile_id) if not profile: raise HTTPException(404, "Nutzer nicht gefunden") recipients = admin_reset_recipients(profile) if not recipients: raise HTTPException(400, "Für dieses Konto ist keine E-Mail hinterlegt") raw = issue_password_reset(conn, profile_id) if not raw: raise HTTPException(429, "Zu viele Reset-Links. Bitte später erneut versuchen.") subject, body = reset_message(raw) jobs.append((recipients, subject, body)) if not _send_jobs(jobs): raise HTTPException(503, "Mailversand ist nicht konfiguriert") return {"ok": True} @router.get("/me") def my_account(session: dict = Depends(require_auth)): with get_db() as conn: prof = profile_view(load_profile(conn, session["profile_id"])) if not prof: raise HTTPException(401, "Profil nicht gefunden") return prof