feat(setup): install-wizard step endpoints (epic 6c, API)

- POST /api/setup/admin (create the first admin: active+verified+admin), /config (persist any
  registry-backed settings — Google creds, SMTP, quota…), /test-email, and /finish (mark
  configured + invalidate the token). All behind require_setup (valid token AND not configured).
- GET /api/setup/info reports secrets_manageable so the wizard hides the Google/SMTP secret steps
  on a box without TOKEN_ENCRYPTION_KEY.
This commit is contained in:
npeter83 2026-06-21 00:40:54 +02:00
parent 283c4c9a1e
commit 2689173208

View file

@ -6,10 +6,15 @@ the one-time setup token (printed to the container logs at first boot, carried b
X-Setup-Token header). The wizard steps themselves are added in epic 6c. X-Setup-Token header). The wizard steps themselves are added in epic 6c.
""" """
from fastapi import APIRouter, Depends, Header, HTTPException from fastapi import APIRouter, Depends, Header, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app import state from app import email as email_mod
from app import state, sysconfig
from app.auth import PASSWORD_MIN_LEN
from app.db import get_db from app.db import get_db
from app.models import User
from app.security import hash_password
router = APIRouter(prefix="/api/setup", tags=["setup"]) router = APIRouter(prefix="/api/setup", tags=["setup"])
@ -30,3 +35,75 @@ def require_setup(
def setup_status(db: Session = Depends(get_db)) -> dict: def setup_status(db: Session = Depends(get_db)) -> dict:
"""Public: whether the instance still needs first-run setup.""" """Public: whether the instance still needs first-run setup."""
return {"configured": state.is_configured(db)} return {"configured": state.is_configured(db)}
@router.get("/info")
def setup_info(db: Session = Depends(require_setup)) -> dict:
"""Capabilities the wizard adapts to: whether secrets (Google creds, SMTP password) can be
stored they need TOKEN_ENCRYPTION_KEY (set in env), so on a box without it the wizard hides
those steps and the operator stays on email+password only."""
return {"secrets_manageable": sysconfig.secrets_manageable()}
@router.post("/admin")
def setup_admin(payload: dict, 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 ""
if "@" not in email:
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if len(password) < PASSWORD_MIN_LEN:
raise HTTPException(
status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters."
)
user = db.execute(select(User).where(User.email == email)).scalar_one_or_none()
if user is None:
user = User(email=email, role="admin")
db.add(user)
user.password_hash = hash_password(password)
user.role = "admin"
user.is_active = True
user.email_verified = True
db.commit()
return {"ok": True, "email": email}
@router.post("/config")
def setup_config(payload: dict, 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)."""
saved: list[str] = []
for key, value in (payload or {}).items():
if sysconfig.spec(key) is None:
raise HTTPException(status_code=400, detail=f"Unknown setting: {key}")
if value is None or value == "":
continue
try:
sysconfig.set_value(db, key, value)
except (ValueError, RuntimeError) as exc:
raise HTTPException(status_code=400, detail=str(exc))
saved.append(key)
return {"saved": saved}
@router.post("/test-email")
def setup_test_email(payload: dict, 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()
if "@" not in to:
raise HTTPException(status_code=400, detail="Enter a valid email address.")
if not email_mod.send_test(to):
raise HTTPException(status_code=400, detail="Couldn't send — check the SMTP settings.")
return {"ok": True}
@router.post("/finish")
def setup_finish(db: Session = Depends(require_setup)) -> dict:
"""Complete setup: require that an admin exists, then mark the instance configured (which
invalidates the setup token and disables the wizard)."""
has_admin = db.execute(select(User).where(User.role == "admin")).first() is not None
if not has_admin:
raise HTTPException(status_code=400, detail="Create the admin account first.")
state.mark_configured(db)
return {"ok": True}