DX-17, three one-liners the epic's canonical sweep did not reach:
`routes/setup.py::setup_admin` still resolved the wizard's admin by
literal `User.email ==`. The wizard can be re-run, and E9.5's premise is
that two spellings of one Gmail mailbox are one person — so a second run
under the other spelling would have created exactly the duplicate admin
row the epic exists to prevent.
`routes/admin.py::_decide` hand-rolled `db.get` + 404 where the file's
newer endpoints use `get_or_404`. There were TWO such sites, not the one
the review named; both are converted, per the house rule that a
principle gets fixed everywhere in one sweep.
`useVerifyEntry`'s `hashchange` listener recognised `#verify=` but not
`#reset=`, so pasting a reset link into a tab already running the app
was silently ignored — the same silence that made the verify half a bug.
It now notices, and says out loud that reset links need a signed-out
window; the fragment is still deliberately left in the address bar
(review round 4), so signing out from that tab recovers it.
DX-16: `.catch(() => setOutcome("invalid"))` could not tell a spent
token from a 502 mid-deploy or a dropped connection, and by then the
fragment is cleared — so a network blip was reported as "that link is no
longer valid", pushing the user to request a fresh one on the strength
of a false statement. Only a 400 carrying {code:"verify"} is the
server's verdict now; everything else gets its own outcome, with no
resend CTA, because the token is untouched and retrying it works. The
rule was already written one file away for the reset-link probe: "a
network blip must never be cached as 'this link is dead'".
Two existing tests asserted the old conflation by rejecting with a bare
Error("400"); they now reject with the real HttpError. The errors are
built inside the rejection, not hoisted: mountAt calls vi.resetModules(),
so an HttpError imported earlier is a different class identity and
`instanceof` would quietly miss.
Gate: vitest 671 -> 676, pytest 924 -> 925.
139 lines
5.9 KiB
Python
139 lines
5.9 KiB
Python
"""First-run install wizard.
|
|
|
|
`GET /api/setup/status` is public (the SPA uses it to decide whether to show the wizard). Every
|
|
mutating step goes through `require_setup`: it rejects once the instance is configured AND requires
|
|
the one-time setup token (printed to the container logs at first boot, carried by the wizard as the
|
|
X-Setup-Token header).
|
|
"""
|
|
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import email as email_mod
|
|
from app import mailtext
|
|
from app import state, sysconfig
|
|
from app.utils import canonical_email
|
|
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"])
|
|
|
|
|
|
def require_setup(
|
|
x_setup_token: str | None = Header(default=None),
|
|
db: Session = Depends(get_db),
|
|
) -> Session:
|
|
"""Gate for setup steps. Returns the db session for the handler to reuse."""
|
|
if state.is_configured(db):
|
|
raise HTTPException(status_code=409, detail="This instance is already set up.")
|
|
if not state.check_setup_token(db, x_setup_token):
|
|
raise HTTPException(status_code=403, detail="Invalid or missing setup token.")
|
|
return db
|
|
|
|
|
|
@router.get("/status")
|
|
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()}
|
|
|
|
|
|
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(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 = 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:
|
|
raise HTTPException(
|
|
status_code=400, detail=f"Password must be at least {PASSWORD_MIN_LEN} characters."
|
|
)
|
|
# Resolve canonically, not by the literal string (DX-17). The wizard can be re-run, and E9.5's
|
|
# whole point is that two spellings of one Gmail mailbox are one person — matching literally here
|
|
# would let a second run under the other spelling create the DUPLICATE admin row the epic exists
|
|
# to prevent. Every other lookup went canonical in S3; this one was outside that diff.
|
|
user = db.execute(
|
|
select(User).where(User.email_canonical == canonical_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(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). Genuinely dynamic
|
|
key→value map, so it stays a typed dict rather than a fixed model."""
|
|
saved: list[str] = []
|
|
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 == "":
|
|
continue
|
|
try:
|
|
sysconfig.set_value(db, key, value)
|
|
except (ValueError, RuntimeError) as exc:
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|
saved.append(key)
|
|
db.commit() # sysconfig.set_value only flushes now — persist the wizard's config in one commit
|
|
return {"saved": saved}
|
|
|
|
|
|
class SetupTestEmailIn(BaseModel):
|
|
to: str = ""
|
|
# The wizard's page language. No account exists yet on a fresh instance, so this is the only
|
|
# thing that can say what language to write in; anything unsupported falls back to English.
|
|
lang: str | None = None
|
|
|
|
|
|
@router.post("/test-email")
|
|
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 = 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, mailtext.normalize(body.lang)):
|
|
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}
|