agnes-the-ai-analyst/app/auth/providers/password.py
Vojtech 2e2e1a1eca
feat(home): state-aware /home + /setup-advanced + schema v26 (#228)
* feat(home+news): state-aware /home + /news + admin-edited news section

Squash of the vr/home-page feature work for clean rebase onto main.
Original 18-commit history preserved in branch backup/vr-home-page-pre-rebase.

What's in this PR:

**State-aware /home page**
- New `/home` route with hero + auto-mode + connectors (Asana / GWS /
  Atlassian) + lookarounds. Onboarded vs not-onboarded state-machine
  branches a single template (`home_not_onboarded.html`); the install
  steps, "Setup a new Claude Code" CTA (90-day PAT mint), and per-
  connector setup prompts hide once `users.onboarded=TRUE`. A
  completion badge replaces them.
- "Mark me as offboarded" button reverses the flag without an SQL UPDATE.
- `users.onboarded BOOLEAN` column added; default FALSE; flipped by the
  CLI's `agnes init` post-success POST and the `/admin/users` API.
- Connector setup prompts pre-check whether the tool is already
  installed/connected before re-running setup.
- GWS scope set widened to include Google Chat (`chat.spaces`,
  `chat.messages`).

**Single template + design tokens**
- `dashboard.html` now extends `base.html` via the new
  `{% block layout %}` opt-out (full-width pages skip the 800px
  `.container`). Net: every page shares one shell.
- `style-custom.css` `:root` extended with `--space-{7,9,10,12}`,
  `--radius-2xl`, `--shadow-{card,elevated}`, `--text-{muted,disabled}`,
  `--focus-ring`, `--transition-*`, `--width-{narrow,app,wide}` so
  inline page styles can migrate incrementally.

**Auth redirects honor AGNES_HOME_ROUTE**
- `safe_next_path` resolves the configured home route when no `default=`
  is passed; OAuth callbacks, magic-link clicks, password form, and
  LOCAL_DEV_MODE shortcuts now land on `/home` (or whatever the operator
  picked) instead of always /dashboard.

**News section + /news permalink + /admin/news editor**
- Schema-bumped `news_template` table (single versioned entity, draft +
  publish gate). `published BOOLEAN` distinguishes draft from public;
  monotonically-increasing `version` per save; rows >30d pruned on
  save except the currently-displayed published version.
- `/home` bottom-of-page renders the latest published intro with a
  "Read more →" link to `/news` (which renders the full body).
- `/admin/news` editor with sandboxed live preview, versions table,
  per-row Unpublish, Format-help cheatsheet.
- `agnes admin news show / draft / edit / publish / unpublish /
  versions / export` (CLI). Talks to the live server via the
  `/api/admin/news/*` endpoints (PAT-authed) — no direct DB access
  so it coexists with a running uvicorn.
- **Optimistic-lock guard**: `agnes admin news publish --version N` and
  PUT/PATCH endpoints accept `expected_version` and 409 with structured
  `{error: "version_conflict", expected, actual, actual_by}` when a
  concurrent admin replaced the draft. Edit refuses to overwrite a
  draft authored by someone else without `--force` or
  `--expect-version`.
- nh3 (Rust-backed ammonia) HTML sanitizer; iframe pre-pass strips
  any iframe whose src is not on the YouTube/Vimeo/Loom allowlist;
  javascript:/data: schemes blocked everywhere.
- Author CSS vocabulary: `.news-hero` (blue gradient hero block),
  `.callout`/`.callout-{info,warn,success,danger}`,
  `.video-embed`, `.news-section`, `.news-grid-{2,3}`, `.news-cta` —
  all consolidated in `style-custom.css` under "News content
  vocabulary (shared)" so /home perex, /news body, and /admin/news
  preview share one source of styling.
- Code-inside-`<pre>` contrast fix (was unreadable amber-on-silver).
- `.news-content` table styling (border, header band, row-hover).

**`scripts/dev/run-local.sh`** — local uvicorn launcher. Pulls Google
OAuth client id/secret from GCP Secret Manager
(`AGNES_OAUTH_GCP_PROJECT`-driven, no vendor defaults), points
`AGNES_CLI_DIST_DIR` at `./dist` so the wheel endpoint resolves, and
`--dev` flips `LOCAL_DEV_MODE=1` + `AGNES_HOME_ROUTE=/home` for one-
command iteration. `LOCAL_DEV_MODE=1` also enables the FastAPI debug
toolbar.

**CLAUDE.md "Run tests before every push" section** codifies
`pytest tests/ -n auto -q` as non-negotiable before each push.

**Tests**: 51 + 14 + 8 = 73 new tests across news-template repo,
sanitizer, API, web, CLI; plus updated home/auth/template tests for
the new shared-shell architecture.

Origin docs (gitignored, customer-fork content):
docs/brainstorms/home-page-requirements.md,
docs/plans/2026-05-07-001-feat-home-page-plan.md.

* feat(cli): agnes onboarded {on,off,status} — self-scoped flag toggle

User-facing equivalent of the in-page "Mark me as (off)boarded" button
on /home. POSTs /api/me/onboarded with {onboarded, source}; --source
overrides the audit-log marker so flips made from the CLI vs the web
button vs agnes init automation stay distinguishable.

`status` reads via /api/me/profile (when present); falls back to a
quick body-marker scan of /home so the read path doesn't write an
audit_log row. PAT-authed via cli.client.api_post — same convention
as agnes admin news / agnes admin add-user etc.

Tests: 5 covering on/off/status round-trip, idempotency, and
audit-log source recording. Full suite holds at 12 pre-existing
failures (same set as before).

* ui(nav+home): primary nav reorg + green What's new band + /marketplace link fix

Primary nav (post-rebase audit + per-user feedback):

- Items: Home → Marketplace → Data Packages → Memory. Admin dropdown
  for admins only. The "Dashboard" label was renamed Home — point still
  resolves through `home_route` so customer instances on /dashboard
  still land there.
- Activity Center moved into the Admin dropdown. Per-team adoption
  analytics is admin-consumed in practice; the route still allows
  any authed user for direct deep-links so existing /home tile +
  bookmarks keep working.
- Memory link added (→ /corporate-memory) — was previously buried in
  the /home "Look around" tiles.
- Setup local agent + My Stack dropped from main nav. Setup is the
  /home install flow's home now; My Stack lives as a tab inside
  /marketplace.

/home tweaks:

- Plugin marketplace tile now points at /marketplace (was /store —
  legacy from before the marketplace rebrand landed in #230).
- "What's new" section header gets a green band (success-flavored
  D1FAE5 background, A7F3D0 border, darker green title) so the
  bottom-of-page news block visibly distinguishes from the blue
  install-hero at the top. Header strip only — body stays white.

Test fix: test_home_route_resolution renamed `dashboard_link_uses_home_route`
→ `home_link_uses_home_route` and asserts `href="/home">Home` instead
of `href="/home">Dashboard` after the label change.

* fix(home): decouple Step 3 + Connect-tools collapse from server onboarded flag

The server-side `users.onboarded` flip happens through two paths:

1. Explicit user click on "Mark me as onboarded" or `agnes onboarded on`.
2. Implicit `agnes init` POST → /api/me/onboarded on success.

Path 2 produced a UX surprise: an analyst running `agnes init` mid-flow
reloaded /home and saw Step 3 (auto-mode) + Connect-your-tools auto-
collapse to summary bars. They were actively working through those
sections — the install POST never signalled "I'm done with the rest
of setup", just "Agnes itself is installed".

Decouple the section-collapse decision from the server flag:

- Step 1 + Step 2 install blocks: still hidden on `onboarded=TRUE`
  (their completion is a hard server signal — Agnes IS installed).
- Step 3 + Connect-your-tools: render flat by default in BOTH states.
  Wrapped in `<details class="setup-collapsible" open>` so the
  browser's native disclosure handles per-section toggle without JS,
  but the `<summary>` is CSS-hidden until the page-level
  `data-setup-minimized="1"` attribute is set on `.home-mock`.
- New "Minimize setup view" toggle inside the blue install-hero,
  rendered only when onboarded. Click flips the data-attr on
  `.home-mock` AND removes the `open` attribute from each
  `<details>`. State persists in `localStorage["agnes_home_setup_minimized"]`
  so the choice survives reloads but is per-device.
- "Show full setup view" (the same button when minimized) re-opens
  both `<details>` and clears localStorage.

When minimized, each `<details>` still has its own native expand/
collapse — click the gray summary bar to peek at one section without
toggling the page-level minimize off.

Tests:
- test_step3_and_connectors_render_flat_when_onboarded_by_default —
  asserts `<details class="setup-collapsible" ... open>` for both
  sections post-onboarding and the absence of any server-rendered
  `data-setup-minimized` attribute on the `.home-mock` root.
- test_minimize_toggle_visible_only_when_onboarded — toggle button
  rendered only when onboarded.

Full pytest holds at 12 pre-existing failures (same set).
2026-05-08 18:28:47 +02:00

538 lines
20 KiB
Python

"""Password auth provider for FastAPI."""
import logging
import os
import secrets
from datetime import datetime, timedelta, timezone
from urllib.parse import quote
from fastapi import APIRouter, Depends, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from pydantic import BaseModel
import duckdb
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
from app.auth.jwt import create_access_token
from app.auth.access import is_user_admin
from app.auth.dependencies import _get_db, is_local_dev_mode
from app.auth.rate_limit import limiter as _rate_limiter
from src.repositories.users import UserRepository
def _role_label(user: dict, conn: duckdb.DuckDBPyConnection) -> str:
"""Display label for the response payload only — `admin` for Admin
group members, `user` otherwise. Authorization at runtime checks
`is_user_admin` directly; this label is purely cosmetic for the
response shape."""
return "admin" if is_user_admin(user["id"], conn) else "user"
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth/password", tags=["auth"])
RESET_TOKEN_TTL = timedelta(hours=24)
SETUP_TOKEN_TTL = timedelta(days=7)
MIN_PASSWORD_LEN = 8
def _audit(user_id: str, action: str, result: str | None = None) -> None:
"""Fire-and-forget audit log entry. Swallows all errors."""
try:
from src.db import get_system_db
from src.repositories.audit import AuditRepository
audit_conn = get_system_db()
AuditRepository(audit_conn).log(
user_id=user_id,
action=action,
resource="auth",
result=result,
)
audit_conn.close()
except Exception:
pass # Audit failure must not block auth
class PasswordLoginRequest(BaseModel):
email: str
password: str
class PasswordSetupRequest(BaseModel):
email: str
token: str
password: str
def is_available() -> bool:
return True # Always available
def _has_email_transport() -> bool:
return bool(os.environ.get("SMTP_HOST") or os.environ.get("SENDGRID_API_KEY"))
def _cookie_secure() -> bool:
# Secure cookie only over HTTPS (DOMAIN env set = production with TLS)
return os.environ.get("DOMAIN", "") != ""
def _set_login_cookie(response, user_id: str, email: str) -> None:
token = create_access_token(user_id, email)
response.set_cookie(
key="access_token", value=token,
httponly=True, max_age=86400, samesite="lax",
secure=_cookie_secure(),
)
def _base_url(request: Request) -> str:
explicit = os.environ.get("SERVER_URL")
if explicit:
return explicit.rstrip("/")
return str(request.base_url).rstrip("/")
def build_reset_url(request: Request, email: str, token: str) -> str:
return f"{_base_url(request)}/auth/password/reset?email={quote(email, safe='')}&token={token}"
def build_setup_url(request: Request, email: str, token: str) -> str:
return f"{_base_url(request)}/auth/password/setup?email={quote(email, safe='')}&token={token}"
def _token_is_fresh(created, ttl: timedelta) -> bool:
if not created:
return False
if isinstance(created, str):
try:
created = datetime.fromisoformat(created)
except ValueError:
return False
# DuckDB returns TIMESTAMP as offset-naive; we stored it as UTC, so assume UTC.
if created.tzinfo is None:
created = created.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - created) <= ttl
def _render_message(request: Request, title: str, message: str, status_code: int = 200):
from app.web.router import templates, _build_context
ctx = _build_context(request, page_title=title, page_message=message)
return templates.TemplateResponse(request, "_message.html", ctx, status_code=status_code)
def _render_reset_form(request: Request, email: str, token: str, error: str = ""):
from app.web.router import templates, _build_context
ctx = _build_context(request, email=email, token=token, error=error)
return templates.TemplateResponse(request, "password_reset.html", ctx)
def _render_setup_form(request: Request, email: str, token: str, name: str = "", error: str = ""):
from app.web.router import templates, _build_context
ctx = _build_context(request, email=email, token=token, name=name, error=error)
return templates.TemplateResponse(request, "password_setup.html", ctx)
def _send_mail(to_email: str, subject: str, body_text: str) -> bool:
"""Send a plaintext email via SendGrid or SMTP. Returns True on success."""
try:
sendgrid_key = os.environ.get("SENDGRID_API_KEY")
if sendgrid_key:
import sendgrid
from sendgrid.helpers.mail import Mail
sg = sendgrid.SendGridAPIClient(api_key=sendgrid_key)
msg = Mail(
from_email=os.environ.get("EMAIL_FROM_ADDRESS", "noreply@example.com"),
to_emails=to_email,
subject=subject,
plain_text_content=body_text,
)
sg.send(msg)
return True
smtp_host = os.environ.get("SMTP_HOST")
if smtp_host:
import smtplib
from email.mime.text import MIMEText
msg = MIMEText(body_text)
msg["Subject"] = subject
msg["From"] = os.environ.get("SMTP_FROM", "noreply@example.com")
msg["To"] = to_email
with smtplib.SMTP(smtp_host, int(os.environ.get("SMTP_PORT", "587"))) as s:
if os.environ.get("SMTP_USE_TLS", "true").lower() == "true":
s.starttls()
smtp_user = os.environ.get("SMTP_USER")
if smtp_user:
s.login(smtp_user, os.environ.get("SMTP_PASSWORD", ""))
s.send_message(msg)
return True
except Exception:
logger.exception("Failed to send mail to %s", to_email)
return False
def send_reset_email(request: Request, email: str, token: str) -> bool:
"""Deliver a password-reset link. In LOCAL_DEV_MODE logs the link as well."""
link = build_reset_url(request, email, token)
if is_local_dev_mode():
logger.warning("=" * 60)
logger.warning("Password reset link for %s (LOCAL_DEV_MODE):", email)
logger.warning(" %s", link)
logger.warning("=" * 60)
if not _has_email_transport():
return False
return _send_mail(email, "Reset your password", f"Click to reset your password: {link}")
def send_setup_email(request: Request, email: str, token: str) -> bool:
link = build_setup_url(request, email, token)
if is_local_dev_mode():
logger.warning("=" * 60)
logger.warning("Account setup link for %s (LOCAL_DEV_MODE):", email)
logger.warning(" %s", link)
logger.warning("=" * 60)
if not _has_email_transport():
return False
return _send_mail(email, "Set up your account", f"Click to set up your password: {link}")
# ---- Existing flows ----
@router.post("/login")
@_rate_limiter.limit("10/minute")
async def password_login(
request: Request,
body: PasswordLoginRequest,
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Login with email + password."""
repo = UserRepository(conn)
user = repo.get_by_email(body.email)
if not user or not user.get("password_hash"):
raise HTTPException(status_code=401, detail="Invalid email or password")
if not bool(user.get("active", True)):
raise HTTPException(status_code=401, detail="Account deactivated")
try:
ph = PasswordHasher()
ph.verify(user["password_hash"], body.password)
except VerifyMismatchError:
raise HTTPException(status_code=401, detail="Invalid email or password")
except Exception:
logger.exception("Unexpected error during password verification")
raise HTTPException(status_code=500, detail="Internal server error")
role_label = _role_label(user, conn)
token = create_access_token(user["id"], user["email"])
return {"access_token": token, "token_type": "bearer", "email": user["email"], "role": role_label}
@router.post("/login/web")
@_rate_limiter.limit("10/minute")
async def password_login_web(
request: Request,
email: str = Form(...),
password: str = Form(""),
next: str = Form(""),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Web form login — sets cookie and redirects to `next` (or /dashboard)."""
repo = UserRepository(conn)
user = repo.get_by_email(email)
if not user or not user.get("password_hash"):
return RedirectResponse(url="/login/password?error=invalid", status_code=302)
if not bool(user.get("active", True)):
return RedirectResponse(url="/login/password?error=deactivated", status_code=302)
try:
ph = PasswordHasher()
ph.verify(user["password_hash"], password)
except VerifyMismatchError:
# M9: audit failed form-login attempts (mirrors /auth/token endpoint)
_audit(user["id"], "login_failed", result="invalid_password")
return RedirectResponse(url="/login/password?error=invalid", status_code=302)
except Exception:
logger.exception("Unexpected error during web password verification for %s", email)
return RedirectResponse(url="/login/password?err=auth_internal", status_code=302)
if next.startswith("/") and not next.startswith("//"):
target = next
else:
from app.instance_config import get_home_route
target = get_home_route()
response = RedirectResponse(url=target, status_code=302)
_set_login_cookie(response, user["id"], user["email"])
return response
# ---- JSON programmatic setup (backward compat — used by existing tests) ----
@router.post("/setup")
@_rate_limiter.limit("10/minute")
async def password_setup(
request: Request,
request_body: PasswordSetupRequest,
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Set initial password using setup token (JSON API).
Rate limited 10/min per IP — same throttle as the form sibling
``/setup/confirm``. Without this, the new web-form throttle is
bypassable: an attacker brute-forcing the ``setup_token`` just
switches to this JSON path and resumes at unbounded RPS.
"""
repo = UserRepository(conn)
user = repo.get_by_email(request_body.email)
if not user:
raise HTTPException(status_code=404, detail="User not found")
if user.get("setup_token") != request_body.token:
raise HTTPException(status_code=400, detail="Invalid setup token")
if not _token_is_fresh(user.get("setup_token_created"), SETUP_TOKEN_TTL):
raise HTTPException(status_code=400, detail="Setup token has expired")
if not bool(user.get("active", True)):
raise HTTPException(status_code=403, detail="Account deactivated")
if len(request_body.password) < MIN_PASSWORD_LEN:
raise HTTPException(status_code=400, detail=f"Password must be at least {MIN_PASSWORD_LEN} characters")
ph = PasswordHasher()
hashed = ph.hash(request_body.password)
repo.update(id=user["id"], password_hash=hashed, setup_token=None, setup_token_created=None)
token = create_access_token(user["id"], user["email"])
return {"access_token": token, "token_type": "bearer", "message": "Password set successfully"}
# ---- Web flow: password RESET ----
@router.get("/reset", response_class=HTMLResponse)
async def reset_page(
request: Request,
email: str = "",
token: str = "",
):
"""Render the 'set new password' form when arriving via reset link."""
if not email or not token:
return RedirectResponse(url="/login/password", status_code=302)
return _render_reset_form(request, email=email, token=token)
@router.post("/reset")
@_rate_limiter.limit("5/minute")
async def reset_request(
request: Request,
email: str = Form(""),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Request a password-reset link. Anti-enumeration: same response regardless.
Rate limited at the same 5/min as ``/auth/email/send-link`` — the
attack surface is identical (single IP rotates random recipient
addresses, anti-enumeration response shape masks which addresses
landed, attacker burns SMTP / SendGrid quota + spams real users).
"""
# Match the rest of the codebase's case-sensitive lookup (password_login,
# email magic-link, admin create). Lowercasing here would silently fail
# for mixed-case emails the admin stored as-is.
email = (email or "").strip()
if email:
repo = UserRepository(conn)
user = repo.get_by_email(email)
if user and bool(user.get("active", True)):
token = secrets.token_urlsafe(32)
repo.update(
id=user["id"],
reset_token=token,
reset_token_created=datetime.now(timezone.utc),
)
send_reset_email(request, email, token)
return _render_message(
request,
title="Check your email",
message="If an account exists for that email, a password-reset link has been sent. "
"The link is valid for 24 hours.",
)
@router.post("/reset/confirm")
@_rate_limiter.limit("10/minute")
async def reset_confirm(
request: Request,
email: str = Form(...),
token: str = Form(...),
password: str = Form(...),
confirm_password: str = Form(...),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Submit a new password using a reset token.
Rate limited 10/min per IP to slow brute-force guessing of the 32-byte
URL-safe ``reset_token`` — the token is high-entropy but logs / proxy
referer leaks have surfaced partial tokens before, and there's no
reason to allow unbounded attempts.
"""
if password != confirm_password:
return _render_reset_form(request, email=email, token=token, error="Passwords do not match.")
if len(password) < MIN_PASSWORD_LEN:
return _render_reset_form(
request, email=email, token=token,
error=f"Password must be at least {MIN_PASSWORD_LEN} characters.",
)
# Atomic compare-and-swap to consume the reset token. Mirrors the
# magic-link CAS in app/auth/providers/email.py::_consume_token (issue
# #82/M10) — without it, two concurrent POSTs with the same valid token
# could both succeed in setting different new passwords. Lower
# severity than the magic-link race (attacker would need the reset
# token AND to race the legitimate user) but closes the asymmetry.
cutoff = datetime.now(timezone.utc) - RESET_TOKEN_TTL
consume_id = f"CONSUMED:{secrets.token_hex(16)}"
try:
conn.execute(
"UPDATE users SET reset_token = ?, reset_token_created = NULL "
"WHERE email = ? AND reset_token = ? AND reset_token_created IS NOT NULL "
"AND reset_token_created >= ? AND active = TRUE",
[consume_id, email, token, cutoff],
)
except Exception as exc:
err = str(exc).lower()
if "conflict" in err or "transaction" in err:
return _render_reset_form(request, email=email, token=token, error="Invalid or expired reset link.")
raise
# Verify OUR marker won the race. A concurrent winner will have a
# different consume_id (or NULL if they already cleared it).
row = conn.execute(
"SELECT reset_token FROM users WHERE email = ?",
[email],
).fetchone()
if not row or row[0] != consume_id:
# Could be: token never matched, expired, account deactivated, or
# the race was lost. Single error keeps the UX simple and avoids
# leaking which condition tripped.
return _render_reset_form(request, email=email, token=token, error="Invalid or expired reset link.")
# Won the race — fetch the user (we need id/email for the response)
# and apply the password change. Clearing the marker happens as part
# of the same UPDATE.
repo = UserRepository(conn)
user = repo.get_by_email(email)
if not user:
return _render_reset_form(request, email=email, token=token, error="Invalid or expired reset link.")
ph = PasswordHasher()
repo.update(
id=user["id"],
password_hash=ph.hash(password),
reset_token=None,
reset_token_created=None,
)
response = RedirectResponse(url="/login/password?msg=password_reset", status_code=302)
_set_login_cookie(response, user["id"], user["email"])
return response
# ---- Web flow: initial SETUP ----
@router.get("/setup", response_class=HTMLResponse)
async def setup_page(
request: Request,
email: str = "",
token: str = "",
):
"""Render the initial 'set password + name' form when arriving via invite link.
Note: we render the form based on URL params only, without a DB lookup, so
the response is identical for valid and invalid email/token combinations
(anti-enumeration). Token validity is checked at POST /setup/confirm."""
if not email or not token:
return RedirectResponse(url="/login/password", status_code=302)
return _render_setup_form(request, email=email, token=token)
@router.post("/setup/request")
@_rate_limiter.limit("5/minute")
async def setup_request(
request: Request,
email: str = Form(""),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Self-service 'Request Access' — emails a setup link if user is pre-approved and unset.
Same 5/min rate limit as ``/auth/password/reset`` and ``/send-link``
— same email-bombing surface (anti-enumeration response, sends mail
on each request).
"""
# Match the rest of the codebase's case-sensitive lookup (password_login,
# email magic-link, admin create). Lowercasing here would silently fail
# for mixed-case emails the admin stored as-is.
email = (email or "").strip()
if email:
repo = UserRepository(conn)
user = repo.get_by_email(email)
# Only issue setup token if user exists, has no password yet, and is active.
if user and not user.get("password_hash") and bool(user.get("active", True)):
token = secrets.token_urlsafe(32)
repo.update(
id=user["id"],
setup_token=token,
setup_token_created=datetime.now(timezone.utc),
)
send_setup_email(request, email, token)
return _render_message(
request,
title="Check your email",
message="If your account is pre-approved, a setup link has been sent to your email. "
"Ask an administrator if you do not receive it.",
)
@router.post("/setup/confirm")
@_rate_limiter.limit("10/minute")
async def setup_confirm(
request: Request,
email: str = Form(...),
token: str = Form(...),
password: str = Form(...),
confirm_password: str = Form(...),
name: str = Form(""),
conn: duckdb.DuckDBPyConnection = Depends(_get_db),
):
"""Web form: complete initial password setup via setup token.
Rate limited 10/min per IP — same rationale as ``/reset/confirm``:
high-entropy ``setup_token`` should still not be brute-forceable at
unbounded RPS in case a partial token leaks via logs / referer.
"""
if password != confirm_password:
return _render_setup_form(request, email=email, token=token, name=name, error="Passwords do not match.")
if len(password) < MIN_PASSWORD_LEN:
return _render_setup_form(
request, email=email, token=token, name=name,
error=f"Password must be at least {MIN_PASSWORD_LEN} characters.",
)
repo = UserRepository(conn)
user = repo.get_by_email(email)
if not user or user.get("setup_token") != token:
return _render_setup_form(request, email=email, token=token, name=name, error="Invalid or expired setup link.")
if not _token_is_fresh(user.get("setup_token_created"), SETUP_TOKEN_TTL):
return _render_setup_form(request, email=email, token=token, name=name, error="Setup link has expired. Ask an administrator for a new one.")
if not bool(user.get("active", True)):
return _render_setup_form(request, email=email, token=token, name=name, error="This account is deactivated.")
ph = PasswordHasher()
updates: dict = dict(
password_hash=ph.hash(password),
setup_token=None,
setup_token_created=None,
)
if name.strip():
updates["name"] = name.strip()
repo.update(id=user["id"], **updates)
from app.instance_config import get_home_route
response = RedirectResponse(url=get_home_route(), status_code=302)
_set_login_cookie(response, user["id"], user["email"])
return response