Twelve mails carried hardcoded English while the app itself has enforced HU/EN parity since E5.
A mail is the one user-facing surface nobody can re-render by switching the UI language: it arrives
once, in whatever language we picked, and stays that way. That made the epic's own headline mail —
"one mail, one click" — English for a Hungarian reader.
The text moved to a catalog (`mailtext.py`) with a `text(key, lang, **vars)` renderer, and the parity
rule is now a test rather than a habit: every key must carry both languages AND the same
`{placeholders}`. A translation that drops `{link}` sends a mail with no link in it; one that invents
a placeholder raises inside a background task nobody is watching. Both are silent in production.
Every `send_*` takes the language as its second argument, resolved by the REQUEST handler and never
inside the mail — these all run as background tasks, after the request's session is gone, which is
the same reason the approval mail computes its link in the handler.
Where the language comes from, in order: the account's own preference; failing that, the language the
person asked us in; failing that, English. The middle one is new — `invites.lang`, migration `0082` —
because the mails an access request produces go to an address that usually has no account yet, and
the signal does exist: the page their form was submitted from, or the locale Google reported for a
sign-in we refused. Captured rather than derived, for the third time in this epic (`google_email` in
S3, `preferences.language` in S6 taught the same lesson): a signal that exists only in one moment
cannot be backfilled later. So the admin is asked in exactly one place — the invitation to an address
they typed in themselves, where nobody has ever told us anything.
That send is now a dialog rather than a bare click, which is also where the "you already wrote to
them" warning belongs — it used to live only in a tooltip the admin may never hover, guarding the one
action whose failure mode is mailing a real person twice.
S6's registration `lang` field turned out to be dead on arrival: the backend accepted it, the
frontend never sent it, so every account has been storing nothing. The request builders now stamp the
UI language themselves — a signal that has to be passed in by every caller is one every caller can
forget, which is exactly how it shipped unset.
Admins are notified one mail per language group instead of one blast to everyone. They are people
with their own UI language; a single message would have to pick one of them and be wrong for the rest.
Also removes a duplicated `invited_at` column declaration on `Invite` (two identical definitions
landed in S6).
132 lines
5.4 KiB
Python
132 lines
5.4 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.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."
|
|
)
|
|
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(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}
|