diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index c319756..576bbc0 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -3,6 +3,7 @@ Also the demo-account admin surface: the demo email whitelist + a manual state r from datetime import datetime, timezone from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from pydantic import BaseModel from sqlalchemy import delete, func, select from sqlalchemy.orm import Session @@ -110,14 +111,18 @@ def deny_invite( return _serialize(_decide(db, invite_id, admin, approved=False)) +class AddInviteIn(BaseModel): + email: str = "" + + @router.post("/invites") def add_invite( - payload: dict, + body: AddInviteIn, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Manually whitelist an email (approved straight away). Convenience for the admin.""" - email = (payload.get("email") or "").strip().lower() + email = body.email.strip().lower() if not valid_email(email): raise HTTPException(status_code=400, detail="Enter a valid email address.") inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none() @@ -157,17 +162,21 @@ def list_users(_: User = Depends(admin_user), db: Session = Depends(get_db)) -> return [_serialize_user(u) for u in rows] +class SetRoleIn(BaseModel): + role: str + + @router.patch("/users/{user_id}/role") def set_user_role( user_id: int, - payload: dict, + body: SetRoleIn, background: BackgroundTasks, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Promote/demote a user. Guards: can't change your own role, can't touch the demo account, and can't remove the last remaining admin. Emails the user when it actually changes.""" - role = payload.get("role") + role = body.role if role not in ("user", "admin"): raise HTTPException(status_code=400, detail="role must be 'user' or 'admin'") target = db.get(User, user_id) @@ -198,10 +207,14 @@ def set_user_role( return _serialize_user(target) +class SetSuspendedIn(BaseModel): + suspended: bool = False + + @router.patch("/users/{user_id}/suspend") def set_user_suspended( user_id: int, - payload: dict, + body: SetSuspendedIn, background: BackgroundTasks, admin: User = Depends(admin_user), db: Session = Depends(get_db), @@ -211,7 +224,7 @@ def set_user_suspended( suspend yourself, and can't suspend the last active admin (that would lock admin out). On un-suspend the user is emailed they can return (suspend is announced reactively, on a blocked sign-in).""" - suspended = bool(payload.get("suspended")) + suspended = body.suspended target = db.get(User, user_id) if target is None: raise HTTPException(status_code=404, detail="No such user") @@ -296,15 +309,20 @@ def list_demo_whitelist( return [_serialize_demo(r) for r in rows] +class AddDemoWhitelistIn(BaseModel): + email: str = "" + note: str | None = None + + @router.post("/demo/whitelist") def add_demo_whitelist( - payload: dict, + body: AddDemoWhitelistIn, admin: User = Depends(admin_user), db: Session = Depends(get_db), ) -> dict: """Add an email that may enter the shared demo account from the login page (no Google sign-in). Idempotent — re-adding an existing email just returns it.""" - email = (payload.get("email") or "").strip().lower() + email = body.email.strip().lower() if not valid_email(email): raise HTTPException(status_code=400, detail="Enter a valid email address.") row = db.execute( @@ -313,7 +331,7 @@ def add_demo_whitelist( if row is None: row = DemoWhitelist( email=email, - note=(payload.get("note") or "").strip() or None, + note=(body.note or "").strip() or None, added_by=admin.email, ) db.add(row) diff --git a/backend/app/routes/setup.py b/backend/app/routes/setup.py index bea3542..be1b716 100644 --- a/backend/app/routes/setup.py +++ b/backend/app/routes/setup.py @@ -5,7 +5,10 @@ mutating step goes through `require_setup`: it rejects once the instance is conf the one-time setup token (printed to the container logs at first boot, carried by the wizard as the X-Setup-Token header). The wizard steps themselves are added in epic 6c. """ +from typing import Any + from fastapi import APIRouter, Depends, Header, HTTPException +from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session @@ -45,12 +48,20 @@ def setup_info(db: Session = Depends(require_setup)) -> dict: return {"secrets_manageable": sysconfig.secrets_manageable()} +class SetupAdminIn(BaseModel): + # Both default to "" so a missing field stays the wizard's own 400 ("Enter a valid email" / + # password too short) rather than a 422. `password: str` also stops a non-string value from + # reaching argon2 (which would 500 in len()/hash_password) — it's now a clean 422. + email: str = "" + password: str = "" + + @router.post("/admin") -def setup_admin(payload: dict, db: Session = Depends(require_setup)) -> dict: +def setup_admin(body: SetupAdminIn, db: Session = Depends(require_setup)) -> dict: """Create (or update) the first administrator. Active + verified immediately — the operator is configuring the instance, so there's no email-verification / approval gate here.""" - email = (payload.get("email") or "").strip().lower() - password = payload.get("password") or "" + email = body.email.strip().lower() + password = body.password if "@" not in email: raise HTTPException(status_code=400, detail="Enter a valid email address.") if len(password) < PASSWORD_MIN_LEN: @@ -70,11 +81,12 @@ def setup_admin(payload: dict, db: Session = Depends(require_setup)) -> dict: @router.post("/config") -def setup_config(payload: dict, db: Session = Depends(require_setup)) -> dict: +def setup_config(settings_map: dict[str, Any], db: Session = Depends(require_setup)) -> dict: """Persist provided DB-backed settings (Google creds, SMTP, quota…). Keys must be in the - sysconfig registry; empty values are skipped (left at the default).""" + sysconfig registry; empty values are skipped (left at the default). Genuinely dynamic + key→value map, so it stays a typed dict rather than a fixed model.""" saved: list[str] = [] - for key, value in (payload or {}).items(): + for key, value in settings_map.items(): if sysconfig.spec(key) is None: raise HTTPException(status_code=400, detail=f"Unknown setting: {key}") if value is None or value == "": @@ -87,10 +99,14 @@ def setup_config(payload: dict, db: Session = Depends(require_setup)) -> dict: return {"saved": saved} +class SetupTestEmailIn(BaseModel): + to: str = "" + + @router.post("/test-email") -def setup_test_email(payload: dict, db: Session = Depends(require_setup)) -> dict: +def setup_test_email(body: SetupTestEmailIn, db: Session = Depends(require_setup)) -> dict: """Send a test email to confirm the SMTP settings the wizard just saved.""" - to = (payload.get("to") or "").strip() + to = body.to.strip() if "@" not in to: raise HTTPException(status_code=400, detail="Enter a valid email address.") if not email_mod.send_test(to):