From c33a04c353dadae669323b71e169e4ae467910e8 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 21 Jun 2026 00:40:54 +0200 Subject: [PATCH] feat(setup): install-wizard step endpoints (epic 6c, API) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- backend/app/routes/setup.py | 79 ++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/backend/app/routes/setup.py b/backend/app/routes/setup.py index a622486..bea3542 100644 --- a/backend/app/routes/setup.py +++ b/backend/app/routes/setup.py @@ -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. """ from fastapi import APIRouter, Depends, Header, HTTPException +from sqlalchemy import select 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.models import User +from app.security import hash_password router = APIRouter(prefix="/api/setup", tags=["setup"]) @@ -30,3 +35,75 @@ def require_setup( def setup_status(db: Session = Depends(get_db)) -> dict: """Public: whether the instance still needs first-run setup.""" 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}