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:
parent
eda9bbbc57
commit
c33a04c353
1 changed files with 78 additions and 1 deletions
|
|
@ -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}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue