From 41b0c6af99ec07a1d75e95648aeaabf6e885305b Mon Sep 17 00:00:00 2001
From: Ben Stull
Date: Thu, 28 May 2026 04:48:02 -0700
Subject: [PATCH] Release 0.17.0: admin-create user + invite email (custom
message; claim-link claim flow)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Roadmap item #16 / §6.1. From the v0.9.0 /admin/users surface, an
admin can now create a user record before that person has ever
signed in — typing first name, last name, email, role, and an
optional custom message — and the framework sends an invite email
carrying a single-use claim link. The invitee clicks through to
/invites/claim?token=…, the token is consumed, the session is
established, and the user is routed to the passcode-set screen on
first sign-in.
New endpoints:
* POST /api/admin/users — admin-only; provisions the users row +
user_invite_tokens row + sends the invite email + writes a
permission_events row with event_kind='user_invited'.
* GET /api/admin/users/invites — admin-only; lists active
(not-claimed, not-expired) invites with the issuing admin.
* POST /api/invites/claim — anonymous-reachable; validates the
token, consumes the row, signs the invitee in (skipping OTC
per the roadmap — clicking the email link is itself proof of
email control), returns needs_passcode for the frontend's
route-onward decision.
Schema: migration slot 019 — user_invite_tokens (id, email, role,
first/last name, custom_message, bcrypt token_hash, expires_at,
created_at, created_by_admin_id, claimed_at, claimed_by_user_id,
invited_user_id). Slot 018 reserved for the parallel #12 release
(per-RFC invitation) shipping in the same wave; distinct table
(rfc_invitations there vs. user_invite_tokens here) so they
coexist cleanly. No users-table changes — the brief floated a
NULL-column discriminator for "(pending invite)" but the existing
users.last_seen_at is NOT NULL with a datetime('now') default, so
the discriminator is the active user_invite_tokens row joined on
invited_user_id; the admin user-listing carries a pending_invite
field populated via that join.
Claim route: frontend /invites/claim?token=… (new
InviteClaim.jsx). Anonymous-reachable; renders "Claim my account"
CTA with an optional v0.11.0-style "trust this device" checkbox,
calls the claim endpoint, routes onward.
Token shape: opaque DB token (256 bits CSPRNG via
secrets.token_urlsafe(32), bcrypt-at-rest), not JWT. Opaque
chosen because admin revocation is then a single SQL UPDATE — JWT
would be stateless but harder to invalidate.
Open-question decisions: immediate-send (no admin-review-then-
send queue; future enhancement), no bulk-invite (deferred to
follow-up; v0.17.0 is one-at-a-time), 7-day expiry as a constant
(INVITE_TOKEN_TTL_DAYS in backend/app/invites.py; env-var
configurability is a §19.2 candidate), OTC skipped on first
sign-in (the token in the email is itself proof of email control;
subsequent sign-ins go through OTC / passcode unchanged).
Refusals on POST /api/admin/users:
* 422 self-invite (use the role-change channel for self-edits)
* 409 duplicate email (use the existing grant/role gestures)
* 422 owner-grant by non-owner admin (§6.1 owner-zero is the
only owner bootstrap path)
* 422 pydantic — malformed email / unknown role /
custom_message > 500 chars
* 403 non-admin caller / 401 anonymous
15 new backend tests in test_admin_create_user_invite_vertical.py
(happy path, all four refusals, claim with valid / expired /
already-claimed / unknown token, pending-invite badge before and
after claim, listing admin-only). 234 total backend tests pass;
frontend build succeeds.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
CHANGELOG.md | 181 ++++-
VERSION | 2 +-
backend/app/api_admin.py | 266 ++++++-
backend/app/email_invite.py | 136 ++++
backend/app/invites.py | 425 ++++++++++++
backend/app/main.py | 109 +++
backend/migrations/019_user_invite_tokens.sql | 105 +++
.../test_admin_create_user_invite_vertical.py | 649 ++++++++++++++++++
frontend/package.json | 2 +-
frontend/src/App.jsx | 5 +
frontend/src/api.js | 47 ++
frontend/src/components/Admin.jsx | 201 ++++++
frontend/src/components/InviteClaim.jsx | 144 ++++
13 files changed, 2268 insertions(+), 4 deletions(-)
create mode 100644 backend/app/email_invite.py
create mode 100644 backend/app/invites.py
create mode 100644 backend/migrations/019_user_invite_tokens.sql
create mode 100644 backend/tests/test_admin_create_user_invite_vertical.py
create mode 100644 frontend/src/components/InviteClaim.jsx
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 25cd9d9..e157966 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,7 +23,186 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
-## 0.14.0 — 2026-05-28
+## 0.17.0 — 2026-05-28
+
+**Minor — schema migration required; no new env vars; no new secrets.**
+This release lands admin-create user with role assignment + invite
+email (roadmap item #16, §6.1). From the v0.9.0 `/admin/users` surface,
+an admin can now type first name, last name, email, role, and an
+optional custom message; the framework provisions the `users` row with
+the chosen role and `permission_state='granted'` (the admin's hand is
+the grant) and sends an invite email carrying a single-use claim link.
+The invitee clicks through to `/invites/claim?token=…`, the token is
+verified and consumed, the session is established, and the user is
+routed to the passcode-set screen (per v0.10.0) on first sign-in.
+
+Distinct from #12 (which ships in parallel in this wave): #12 is
+per-RFC contribution/discussion membership and uses
+`rfc_invitations` (slot 018). v0.17.0 is platform-level access
+provisioning by an admin and uses `user_invite_tokens` (slot 019).
+Both can coexist; both surface in the same SMTP relay but with
+distinct email templates.
+
+Design decisions documented inline (see `backend/app/invites.py`'s
+module docstring + the migration's header comment):
+
+ * **Token shape: opaque DB token, not JWT.** 256 bits of CSPRNG
+ entropy (`secrets.token_urlsafe(32)`), bcrypt-hashed at rest.
+ Opaque chosen over JWT because revocation is then a single SQL
+ UPDATE — admin-issued invites are exactly the kind of thing an
+ admin should be able to yank back without rotating a signing
+ key. The raw token only ever lives in the outbound email link
+ and the inbound claim body.
+ * **TTL: 7 days, hard-coded constant** (`INVITE_TOKEN_TTL_DAYS`
+ in `backend/app/invites.py`). Env-var configurability is a
+ §19.2 candidate; the constant is exposed as a single point of
+ edit if a deployment wants to override.
+ * **Immediate send, no admin-review-then-send queue.** Matches
+ how the v0.9.0 beta-request admin notification works (single
+ SMTP path). Admin-preview-before-send is a future enhancement.
+ * **Bulk-invite (CSV paste) deferred.** v0.17.0 is one-at-a-time;
+ a follow-up release can layer bulk on top of the same
+ `POST /api/admin/users` body shape with minimal disruption.
+ * **OTC skipped on first sign-in.** Per the roadmap: clicking the
+ unique token in the email is itself proof of email control, so
+ the claim flow signs the invitee in directly. Subsequent
+ sign-ins go through the standard OTC / passcode paths.
+ * **No `users` table changes.** The brief floated
+ `first_sign_in_at` / `last_seen_at IS NULL` as the
+ "(pending invite)" discriminator, but the existing
+ `users.last_seen_at` column is NOT NULL with a `datetime('now')`
+ default (migrations/001) and no `first_sign_in_at` column
+ exists. Rather than land a schema migration to introduce one,
+ the discriminator is the existence of an active (not-claimed,
+ not-expired) row in `user_invite_tokens` joined on
+ `invited_user_id`. The admin user-listing carries a
+ `pending_invite` field populated via that join; on claim, the
+ badge clears naturally as the invite row's `claimed_at`
+ populates.
+ * **Admin-create vs. self-flip refusals.** Self-invite is refused
+ 422 (use the role-change channel for self-edits). Duplicate
+ email is refused 409 (use the existing role / grant gestures on
+ the existing user). Owner-grant by a non-owner admin is refused
+ 422 (§6.1's owner-zero is the only owner bootstrap path; the
+ sitting owner must issue the invite).
+
+### Added
+
+- **`POST /api/admin/users`** (`backend/app/api_admin.py`) — admin-only.
+ Body: `{ email, first_name?, last_name?, role, custom_message? }`.
+ Provisions the `users` row with the chosen role and writes the
+ `user_invite_tokens` row + dispatches the invite email + writes a
+ `permission_events` row with `event_kind='user_invited'`. Returns
+ `{ ok, invite_id, invited_user_id, email, role }` on success;
+ surfaces the four refusals (403/422/409/422) per their distinct
+ paths.
+- **`GET /api/admin/users/invites`** — admin-only. Lists active
+ (not claimed, not expired) invites with the admin who created them
+ joined through for display. Powers the "I sent these but they
+ haven't been claimed yet" admin view.
+- **`POST /api/invites/claim`** (`backend/app/main.py` — alongside
+ `/auth/otc/verify` and `/auth/device-trust/start` since it shares
+ the device-trust cookie helpers). Anonymous-reachable. Body:
+ `{ token, trust_device? }`. Validates the token, consumes the
+ invite row, signs the user in, optionally mints a device-trust
+ cookie, and returns `{ ok, user, needs_passcode }`. The
+ `needs_passcode` hint drives the frontend's route-to-passcode-set
+ vs. route-to-home decision. Maps token-failure modes to distinct
+ HTTP statuses: expired/claimed → 410, unknown/invalid → 400.
+- **`backend/app/invites.py`** — the create + claim + list module.
+ Mirrors the `device_trust.py` shape: opaque-token issuance with
+ bcrypt-at-rest, candidate-set walk on lookup, dataclass-bracketed
+ outcomes (`CreateOutcome` / `ClaimOutcome` / `PendingInviteRow`).
+ Carries the `INVITE_TOKEN_TTL_DAYS = 7` constant and the
+ `CUSTOM_MESSAGE_MAX_LENGTH = 500` mirror of the API-side bound.
+- **`backend/app/email_invite.py`** — sibling of `email_otc.py`. Reuses
+ `EmailConfig.from_env()` for the SMTP plumbing + From identity;
+ composes a separate template (subject "You're invited to by
+ "; body names the inviter, embeds the optional custom
+ message in a clearly-delimited indented block if present, and
+ carries the claim URL). Dev / no-SMTP path logs the envelope to
+ the shared `_SENT` buffer so backend tests can assert on the
+ outbound shape.
+- **Schema migration `019_user_invite_tokens.sql`** — new
+ `user_invite_tokens` table (id, email, role, first_name, last_name,
+ custom_message, token_hash, expires_at, created_at,
+ created_by_admin_id, claimed_at, claimed_by_user_id,
+ invited_user_id). Three indexes: unique on `token_hash`
+ (documents the no-collision invariant); `(email, claimed_at)` for
+ the "is this email already invited?" pre-check; and
+ `(created_by_admin_id, created_at DESC)` for the per-admin
+ invites listing. Slot 018 is reserved for the parallel #12
+ release shipping in the same wave; slot 016 stays
+ reserved-and-skipped per Session K's v0.9.0 integration.
+- **`frontend/src/components/InviteClaim.jsx`** — the
+ `/invites/claim?token=…` landing page. Reads the token from the
+ URL, renders a "Claim my account" CTA with an optional
+ "trust this device for 30 days" checkbox, calls
+ `POST /api/invites/claim` on submit, and routes onward
+ (`/settings/notifications#sign-in` if `needs_passcode`, else `/`)
+ on success. Anonymous-reachable.
+- **"Create user + invite" affordance** on `/admin/users`
+ (`frontend/src/components/Admin.jsx`). A header button opens a
+ modal with email / first / last / role / custom-message inputs
+ (the textarea shows a "chars left" counter against the 500-char
+ ceiling). On submit, the modal calls
+ `POST /api/admin/users` and refreshes the user listing.
+- **"(pending invite)" badge** inline on the user-listing's
+ per-row handle (rendered when the row's `pending_invite` field
+ is populated by the backend's join through `user_invite_tokens`).
+ Clears automatically on claim as the invite row's `claimed_at`
+ populates.
+
+### Changed
+
+- **`backend/app/api_admin.py`** — imports `invites` + `email_invite`
+ + `EmailConfig`; adds the two new endpoints alongside the existing
+ `set_role` / `set_permission` neighbors; extends `list_users` to
+ join through `user_invite_tokens` and emit the `pending_invite`
+ field on each row. The new `CreateUserInviteBody` pydantic model
+ carries the body bounds (320-char email, 120-char first/last,
+ regex-pinned role, 500-char custom_message) so malformed input
+ fails at the body bound (422) instead of at the SQL layer.
+- **`backend/app/main.py`** — imports `invites as invites_mod`;
+ adds the `InviteClaimBody` pydantic model alongside
+ `PasscodeVerifyBody`; mounts the `POST /api/invites/claim`
+ endpoint in the OAuth router so it can reuse the
+ `_set_device_trust_cookie` helper.
+- **`frontend/src/api.js`** — exports `createUserInvite()`,
+ `listUserInvites()`, `claimInvite()`. Same fetch shape as the
+ rest of the v0.9.0 / v0.10.0 admin neighborhood.
+- **`frontend/src/App.jsx`** — imports `InviteClaim`; registers the
+ `/invites/claim` route alongside `/beta-pending` (both are
+ anonymous-reachable auth-shape landings).
+
+### Migration
+
+- **`backend/migrations/019_user_invite_tokens.sql`** — auto-applied
+ on next backend start. Single new table with three indexes; no
+ changes to existing tables.
+
+### Upgrade steps (from 0.14.0)
+
+- You **MUST** apply schema migration `019_user_invite_tokens.sql`.
+ The migration creates a single new table with three indexes; the
+ framework runs migrations automatically at process start, so no
+ manual step is required beyond restarting the backend so the
+ migration runner picks the file up.
+- You **MUST** rebuild the frontend and restart the backend after
+ upgrading. `frontend/package.json#version` and `VERSION` both
+ move to `0.17.0`. No new env vars; no new secrets (the invite
+ email rides the existing SMTP relay configured for v0.7.0's OTC
+ mail).
+- You **MAY** announce the new admin-create gesture to existing
+ admins. Existing user rows are unaffected — the
+ `user_invite_tokens` table is empty post-migration, and the
+ user-listing's new `pending_invite` field is null on every
+ existing row. The bootstrap shape for the very first admin
+ account stays the v0.9.0 path (DB-level role flip on an OTC-
+ provisioned row); the v0.17.0 admin-create gesture works
+ end-to-end once at least one admin exists.
+
+
**Minor — no operator action required; new optional env var.** This
release ships `DOCS.md` and the `/docs` route — a public-facing user
diff --git a/VERSION b/VERSION
index ac454c6..c5523bd 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.12.0
+0.17.0
diff --git a/backend/app/api_admin.py b/backend/app/api_admin.py
index f4031de..0b640d0 100644
--- a/backend/app/api_admin.py
+++ b/backend/app/api_admin.py
@@ -11,6 +11,8 @@ The endpoints in this module:
- `GET /api/admin/users` — list users with role + mute
- `POST /api/admin/users//role` — set role per §6.1
- `POST /api/admin/users//mute` — set the §6.2 write-mute
+ - `POST /api/admin/users` — v0.17.0: create user + invite
+ - `GET /api/admin/users/invites` — v0.17.0: pending invites
- `GET /api/admin/audit` — paged `actions` log
- `GET /api/admin/permission-events` — paged `permission_events` log
- `GET /api/admin/graduation-queue` — super-drafts ready to graduate
@@ -33,8 +35,9 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Query, Request
from pydantic import BaseModel, Field
-from . import auth, db
+from . import auth, db, email_invite, invites
from .config import Config
+from .email import EmailConfig
# ---------------------------------------------------------------------------
@@ -65,6 +68,32 @@ class AllowlistAddBody(BaseModel):
note: str | None = Field(default=None, max_length=200)
+class CreateUserInviteBody(BaseModel):
+ """v0.17.0 / roadmap item #16 — admin-create user + invite email.
+
+ The admin types these fields on the "Create user + invite" modal on
+ `/admin/users`. The email + role are required; first/last name and
+ the optional custom message round out the body.
+
+ Bounds mirror the rest of the codebase:
+ * `email`: 320 chars — RFC 5321 envelope limit, same as
+ `OtcRequestBody` / `BetaRequestBody` / `AllowlistAddBody`.
+ * `first_name` / `last_name`: 120 chars — same as the v0.8.0
+ `BetaRequestBody` capture form.
+ * `role`: pydantic regex pinned to the §6.1 set so an unknown
+ role fails at the body bound (422) instead of landing as a
+ CHECK constraint violation in the migration.
+ * `custom_message`: 500 chars — the brief calls this out as
+ the max. The frontend modal shows a "remaining chars"
+ counter to match.
+ """
+ email: str = Field(min_length=3, max_length=320)
+ first_name: str = Field(default="", max_length=120)
+ last_name: str = Field(default="", max_length=120)
+ role: str = Field(pattern="^(owner|admin|contributor)$")
+ custom_message: str = Field(default="", max_length=500)
+
+
# ---------------------------------------------------------------------------
# Router
# ---------------------------------------------------------------------------
@@ -115,6 +144,32 @@ def make_router(config: Config) -> APIRouter:
u.display_name COLLATE NOCASE
"""
).fetchall()
+ # v0.17.0 / roadmap item #16: a user row whose `last_seen_at`
+ # is NULL is one of two things — a brand-new row that was just
+ # provisioned (rare, and the v0.7.0 OTC verify path stamps
+ # last_seen_at on the same call that creates the row), or an
+ # admin-created invite-pending row (v0.17.0 — created by
+ # `POST /api/admin/users`). We surface a `pending_invite_id`
+ # field by joining through `user_invite_tokens` so the
+ # Users tab can render a "(pending invite)" badge alongside
+ # the role/state controls. Filters to invites that are
+ # neither expired nor claimed — once the invitee clicks
+ # through, the badge clears (and `last_seen_at` populates).
+ pending_invite_rows = db.conn().execute(
+ """
+ SELECT invited_user_id, id AS invite_id, expires_at
+ FROM user_invite_tokens
+ WHERE claimed_at IS NULL
+ AND datetime(expires_at) > datetime('now')
+ """
+ ).fetchall()
+ pending_invites = {
+ r["invited_user_id"]: {
+ "invite_id": r["invite_id"],
+ "expires_at": r["expires_at"],
+ }
+ for r in pending_invite_rows
+ }
return {
"items": [
{
@@ -133,6 +188,215 @@ def make_router(config: Config) -> APIRouter:
"permission_decided_at": r["permission_decided_at"],
"permission_decided_by_login": r["decided_by_login"],
"permission_decided_by_display": r["decided_by_display"],
+ # v0.17.0: present iff the row is invited-but-not-
+ # claimed-yet. The frontend renders a "(pending
+ # invite)" badge when this is non-null.
+ "pending_invite": pending_invites.get(r["id"]),
+ }
+ for r in rows
+ ]
+ }
+
+ # ----- Create user + invite (v0.17.0 / roadmap item #16) -----
+
+ @router.post("/api/admin/users")
+ async def create_user_with_invite(
+ body: CreateUserInviteBody, request: Request,
+ ) -> dict[str, Any]:
+ """Provision a fresh `users` row with a pre-assigned role + send
+ an invite email carrying a claim link.
+
+ Refusals:
+ * `422` — the admin tries to invite their own email (no
+ self-invite; symmetric to `set_permission`'s self-flip
+ refusal and `set_role`'s self-downgrade refusal). Use
+ the existing role-change channel for self-edits.
+ * `422` — the admin tries to grant `owner` without being
+ owner themselves. §6.1: owner-zero is the only owner
+ bootstrap path; new owners come from a sitting owner's
+ hand. A 422 here matches the message shape; a 403 would
+ also be defensible, but staying with 422 keeps the
+ "your input is bad" framing.
+ * `409` — the email already maps to a `users` row. The
+ admin should use the existing role / grant gestures on
+ the existing user, not create a duplicate.
+ * `422` — pydantic-level: malformed email, role outside
+ the §6.1 set, custom_message over 500 chars.
+
+ On success:
+ 1. The invitee `users` row lands with the chosen role and
+ `permission_state='granted'` (admin's hand is the grant)
+ and `last_seen_at IS NULL` (the "(pending invite)"
+ discriminator the listing surface joins through).
+ 2. The `user_invite_tokens` row lands with the bcrypt-
+ hashed opaque token; the raw token rides only in the
+ email link.
+ 3. The invite email dispatches with subject "You're
+ invited to by " and the custom message
+ embedded in a clearly-delimited block if present.
+ 4. A `permission_events` row records the admin-create
+ gesture so the §6.5 / `permissions` admin tab carries
+ the audit trail alongside the existing grant/revoke
+ flips.
+ """
+ viewer = auth.require_admin(request)
+ email_clean = body.email.strip().lower()
+ if "@" not in email_clean or len(email_clean.split("@")[-1]) < 2:
+ raise HTTPException(422, "Email looks malformed")
+
+ # Self-invite refusal. Compare the admin's own email
+ # case-insensitively against the invite target.
+ viewer_row = db.conn().execute(
+ "SELECT email FROM users WHERE id = ?", (viewer.user_id,)
+ ).fetchone()
+ viewer_email = (viewer_row["email"] or "").strip().lower() if viewer_row else ""
+ if viewer_email and viewer_email == email_clean:
+ raise HTTPException(
+ 422,
+ "You cannot invite yourself — use the role-change channel "
+ "if you need to edit your own row",
+ )
+
+ # Owner-grant refusal: §6.1 says only a sitting owner can mint
+ # a new owner. An admin trying to invite-as-owner is refused
+ # at 422; the admin should ask the owner to issue the invite,
+ # or invite as `admin` and let the owner promote later.
+ if body.role == "owner" and viewer.role != "owner":
+ raise HTTPException(
+ 422,
+ "Only an owner can invite a new owner — invite as admin and "
+ "ask the owner to promote, or have the owner issue this invite",
+ )
+
+ # Duplicate-email refusal. A pre-existing row (regardless of
+ # permission_state) means the admin should use the existing
+ # role / grant gestures, not create a parallel user.
+ existing = db.conn().execute(
+ "SELECT id FROM users WHERE email = ? COLLATE NOCASE LIMIT 1",
+ (email_clean,),
+ ).fetchone()
+ if existing is not None:
+ raise HTTPException(409, "A user with this email already exists")
+
+ # Create the invitee row + token row + send the email.
+ outcome = invites.create_invite(
+ email=email_clean,
+ first_name=body.first_name,
+ last_name=body.last_name,
+ role=body.role,
+ custom_message=body.custom_message,
+ created_by_admin_id=viewer.user_id,
+ )
+
+ # Audit row in permission_events so the admin Permissions tab
+ # carries the gesture. The before-state is "n/a" (the row
+ # did not exist); the after-state is the granted role. We
+ # use a new `event_kind='user_invited'` so the existing
+ # grant/revoke kinds stay scoped to their flip surface.
+ db.conn().execute(
+ """
+ INSERT INTO permission_events
+ (actor_user_id, subject_user_id, event_kind, details)
+ VALUES (?, ?, 'user_invited', ?)
+ """,
+ (
+ viewer.user_id,
+ outcome.invited_user_id,
+ json.dumps({
+ "email": email_clean,
+ "role": body.role,
+ "invite_id": outcome.invite_id,
+ "custom_message_chars": len(body.custom_message or ""),
+ }),
+ ),
+ )
+
+ # Build the claim URL using the same APP_URL the email module
+ # reads. The token rides as a query-string param to the
+ # frontend route `/invites/claim?token=…`; the frontend POSTs
+ # it back to `/api/invites/claim` which consumes the row.
+ cfg = EmailConfig.from_env()
+ from urllib.parse import urlencode
+ claim_url = f"{cfg.app_url}/invites/claim?{urlencode({'token': outcome.raw_token})}"
+
+ # Fetch the inviter display so the email body can render
+ # "Ben Stull (ben@example.com) has invited you to …". We
+ # read off the row fresh rather than trusting the session
+ # cookie's cached display_name.
+ inviter_row = db.conn().execute(
+ "SELECT display_name, email FROM users WHERE id = ?",
+ (viewer.user_id,),
+ ).fetchone()
+ inviter_display = (
+ (inviter_row["display_name"] if inviter_row else "") or viewer.display_name or "An admin"
+ )
+ inviter_email_for_body = (inviter_row["email"] if inviter_row else "") or viewer.email or ""
+
+ email_invite.send_invite_email(
+ to_address=email_clean,
+ claim_url=claim_url,
+ inviter_display=inviter_display,
+ inviter_email=inviter_email_for_body,
+ custom_message=body.custom_message,
+ )
+
+ return {
+ "ok": True,
+ "invite_id": outcome.invite_id,
+ "invited_user_id": outcome.invited_user_id,
+ "email": email_clean,
+ "role": body.role,
+ }
+
+ @router.get("/api/admin/users/invites")
+ async def list_user_invites(request: Request) -> dict[str, Any]:
+ """List active (not claimed, not expired) admin-issued invites.
+
+ Powers the admin's "I sent these but they haven't been claimed
+ yet" view. The frontend uses this alongside `list_users` —
+ the user-listing's `pending_invite` field carries the per-row
+ flag; this endpoint carries the full invite shape for a
+ dedicated drill-in surface.
+ """
+ auth.require_admin(request)
+ rows = invites.list_pending_invites()
+ # Join through to the admin display names so the surface can
+ # render "invited by @ben" without a second client call.
+ admin_ids = {r.created_by_admin_id for r in rows}
+ admin_lookup: dict[int, dict[str, str]] = {}
+ if admin_ids:
+ placeholders = ",".join("?" * len(admin_ids))
+ admin_rows = db.conn().execute(
+ f"SELECT id, gitea_login, display_name FROM users "
+ f"WHERE id IN ({placeholders})",
+ tuple(admin_ids),
+ ).fetchall()
+ admin_lookup = {
+ ar["id"]: {
+ "gitea_login": ar["gitea_login"] or "",
+ "display_name": ar["display_name"] or "",
+ }
+ for ar in admin_rows
+ }
+ return {
+ "items": [
+ {
+ "id": r.id,
+ "email": r.email,
+ "role": r.role,
+ "first_name": r.first_name,
+ "last_name": r.last_name,
+ "custom_message": r.custom_message,
+ "created_at": r.created_at,
+ "expires_at": r.expires_at,
+ "invited_user_id": r.invited_user_id,
+ "created_by_admin_id": r.created_by_admin_id,
+ "created_by_login": admin_lookup.get(
+ r.created_by_admin_id, {}
+ ).get("gitea_login", ""),
+ "created_by_display": admin_lookup.get(
+ r.created_by_admin_id, {}
+ ).get("display_name", ""),
}
for r in rows
]
diff --git a/backend/app/email_invite.py b/backend/app/email_invite.py
new file mode 100644
index 0000000..9b9d96f
--- /dev/null
+++ b/backend/app/email_invite.py
@@ -0,0 +1,136 @@
+"""Outbound admin-invite email — a thin wrapper over the existing SMTP layer.
+
+v0.17.0 / roadmap item #16: when an admin uses `POST /api/admin/users` to
+create-with-invite, this module composes and sends the invite envelope.
+
+Structurally distinct from:
+
+ * `email_otc.py` (v0.7.0) — that one carries a credential the user
+ just requested; this one carries a credential the admin is sending
+ unsolicited.
+ * `email.py` (§15.4 notification mailer) — that one is inbox-driven,
+ bundled, with category opt-outs; this one is a single transactional
+ outbound to a person who does not yet have an inbox.
+ * v0.9.0's `new_beta_request` admin notification — that one is
+ invitee-to-admin (an existing pending user asking to be let in);
+ this one is admin-to-invitee (an admin reaching out to seed access).
+
+So this module reuses `EmailConfig.from_env()` for the SMTP plumbing
+and the From identity, but writes its own envelope. In dev (no
+SMTP_HOST set), the envelope is logged at INFO level and pushed to
+the same `_SENT` buffer the notification mailer uses, so the
+integration tests can assert on the outbound shape without standing
+up an SMTP server.
+
+The send is synchronous. The admin endpoint returns 200 on the
+create-row half regardless of send outcome — a transient SMTP
+failure should not roll back the invite (an admin can re-send via a
+future "resend invite" gesture, deferred to a follow-up release).
+"""
+from __future__ import annotations
+
+import logging
+import smtplib
+from email.message import EmailMessage
+from email.utils import formataddr
+
+from .email import EmailConfig, _SENT
+
+log = logging.getLogger(__name__)
+
+
+def send_invite_email(
+ *,
+ to_address: str,
+ claim_url: str,
+ inviter_display: str,
+ inviter_email: str,
+ custom_message: str = "",
+) -> bool:
+ """Compose and send the admin-invite email. Returns True on the
+ happy path; False on SMTP failure. The notifier-side buffer
+ `_SENT` is appended either way so tests can assert on content.
+
+ The body names the inviting admin, embeds the optional custom
+ message in a clearly delimited block if present, and ships the
+ claim link. The subject names the inviter so the recipient can
+ recognize the sender at a glance in their inbox preview.
+ """
+ cfg = EmailConfig.from_env()
+ subject = _subject(inviter_display, cfg)
+ body = _body(claim_url, inviter_display, inviter_email, custom_message, cfg)
+ envelope = {
+ "to": to_address,
+ "from": formataddr((cfg.from_name, cfg.from_address)),
+ "subject": subject,
+ "body": body,
+ "kind": "invite",
+ }
+ _SENT.append(envelope)
+
+ if not cfg.enabled:
+ log.info("invite email disabled (EMAIL_ENABLED=0): to=%s", to_address)
+ return True
+ if not cfg.smtp_host:
+ # Dev fallback: surface the claim URL at INFO so the operator can
+ # complete a claim flow without an SMTP relay. In production
+ # SMTP_HOST is always set per OHM's overlay.
+ log.info("invite email (stdout fallback): to=%s claim_url=%s", to_address, claim_url)
+ return True
+
+ try:
+ msg = EmailMessage()
+ msg["From"] = envelope["from"]
+ msg["To"] = to_address
+ msg["Subject"] = subject
+ msg.set_content(body)
+ smtp = smtplib.SMTP(cfg.smtp_host, cfg.smtp_port, timeout=30)
+ try:
+ if cfg.smtp_starttls:
+ smtp.starttls()
+ if cfg.smtp_user:
+ smtp.login(cfg.smtp_user, cfg.smtp_password)
+ smtp.send_message(msg)
+ finally:
+ smtp.quit()
+ return True
+ except Exception:
+ log.exception("invite email send failed: to=%s", to_address)
+ return False
+
+
+def _subject(inviter_display: str, cfg: EmailConfig) -> str:
+ """e.g. "You're invited to Wiggleverse by Ben Stull"."""
+ inviter = inviter_display or "an admin"
+ return f"You're invited to {cfg.from_name} by {inviter}"
+
+
+def _body(
+ claim_url: str,
+ inviter_display: str,
+ inviter_email: str,
+ custom_message: str,
+ cfg: EmailConfig,
+) -> str:
+ inviter = inviter_display or "An admin"
+ inviter_suffix = f" ({inviter_email})" if inviter_email else ""
+ message_block = ""
+ if custom_message.strip():
+ # Indent the custom message so it reads as a clearly-delimited
+ # quote rather than running together with the framework's
+ # framing text. Per-line indent keeps multi-line messages
+ # visually grouped in plain-text mail clients.
+ indented = "\n".join(f" {line}" for line in custom_message.strip().splitlines())
+ message_block = f"\nA personal note from {inviter}:\n\n{indented}\n"
+
+ return (
+ f"{inviter}{inviter_suffix} has invited you to {cfg.from_name}.\n"
+ f"{message_block}\n"
+ f"Click the link below to claim your account and sign in.\n"
+ f"This link is single-use and expires in 7 days.\n\n"
+ f" {claim_url}\n\n"
+ f"If you weren't expecting this invitation, you can ignore this\n"
+ f"email — no account becomes active until you click the link.\n\n"
+ f"---\n"
+ f"{cfg.from_name} · {cfg.app_url}\n"
+ )
diff --git a/backend/app/invites.py b/backend/app/invites.py
new file mode 100644
index 0000000..a5abcc5
--- /dev/null
+++ b/backend/app/invites.py
@@ -0,0 +1,425 @@
+"""§6.1 / v0.17.0: admin-create user with role + invite email (roadmap item #16).
+
+Distinguishes from the v0.8.0 self-serve beta-access flow:
+
+ * **Self-serve (v0.8.0)** — anyone with an email can request OTC sign-in;
+ a fresh `users` row lands in `permission_state='pending'`; an admin
+ grants or revokes via the v0.9.0 user-management page.
+
+ * **Admin-create (v0.17.0)** — an admin types first/last/email/role
+ *before* the invitee has signed in. The framework provisions the
+ `users` row with the chosen role and `permission_state='granted'`
+ (the admin's hand is the grant) and `last_seen_at IS NULL` as the
+ "invited but not yet arrived" discriminator. An invite-token row
+ lands in `user_invite_tokens`; the admin's chosen `custom_message`
+ (if any) rides in the email body alongside the claim link.
+
+ * **Claim flow** — the invitee clicks the link, which lands them at
+ `/invites/claim?token=…`. The page POSTs `/api/invites/claim` with
+ the token. The framework verifies the token (not expired, not
+ claimed, hash matches), marks the row claimed, signs the user in,
+ and returns a payload telling the frontend whether to route to
+ passcode-set (if v0.10.0 passcode flow is in play and the user has
+ no passcode yet) or to `/`. **No OTC roundtrip** — clicking the
+ unique token in the email is itself proof of email control, per
+ the roadmap. This is the intentional UX shortcut for first
+ sign-in; subsequent sign-ins use the standard OTC / passcode
+ paths.
+
+The shape:
+
+ * `create_invite(...)` — provision the invitee `users` row + the
+ `user_invite_tokens` row, return the raw token for the admin
+ endpoint to put in the outbound email link.
+ * `claim(raw_token)` — validate the token, mark it claimed, return
+ the `SessionUser` the endpoint signs in. Distinguishes the failure
+ modes (`expired`, `claimed`, `unknown`, `invalid`) so the endpoint
+ can map them to HTTP 410 vs HTTP 404 cleanly.
+ * `list_pending_invites()` — return active invites for the admin
+ listing surface. Filters out claimed + expired rows so the surface
+ only shows live invites.
+
+Token shape: opaque DB token (256 bits of CSPRNG entropy via
+`secrets.token_urlsafe(32)`), bcrypt-hashed at rest. Opaque chosen
+over JWT because revocation is then a single SQL UPDATE — a JWT
+would be stateless but harder to invalidate, and admin-issued
+invites are exactly the kind of thing an admin should be able to
+yank back. The raw token only ever lives in the outbound email link
+and the inbound claim body; server-side storage is the hash.
+
+TTL: hard-coded to 7 days via `INVITE_TOKEN_TTL_DAYS`. Env-var
+configurability is a §19.2 candidate — the constant is exposed
+here as a single point of edit if a deployment wants to override.
+
+The 500-char ceiling on `custom_message` is enforced at the
+Pydantic body level in `api_admin.py`; this module trusts what
+the endpoint hands it.
+"""
+from __future__ import annotations
+
+import logging
+import secrets
+from dataclasses import dataclass
+
+import bcrypt
+
+from . import db
+from .auth import SessionUser
+
+log = logging.getLogger(__name__)
+
+
+# ---------------------------------------------------------------------------
+# Tunables — intentionally hard-coded in v0.17.0 (§19.2 candidate to env-ify).
+# ---------------------------------------------------------------------------
+
+INVITE_TOKEN_TTL_DAYS = 7
+# 256 bits of CSPRNG entropy. `secrets.token_urlsafe(32)` yields ~43
+# URL-safe characters; the bcrypt hash is what's stored, so the raw
+# token only ever lives in the outbound email link.
+TOKEN_BYTES = 32
+# Free-text ceiling for the admin's optional custom message. Matched
+# at the Pydantic body bound in `api_admin.py`; mentioned here so the
+# bound is documented in one place.
+CUSTOM_MESSAGE_MAX_LENGTH = 500
+
+
+# ---------------------------------------------------------------------------
+# Token + hash helpers (mirror device_trust.py shape)
+# ---------------------------------------------------------------------------
+
+
+def _new_token() -> str:
+ return secrets.token_urlsafe(TOKEN_BYTES)
+
+
+def _hash(token: str) -> str:
+ return bcrypt.hashpw(token.encode("utf-8"), bcrypt.gensalt()).decode("ascii")
+
+
+def _check(token: str, token_hash: str) -> bool:
+ try:
+ return bcrypt.checkpw(token.encode("utf-8"), token_hash.encode("ascii"))
+ except (ValueError, TypeError):
+ return False
+
+
+# ---------------------------------------------------------------------------
+# Create
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class CreateOutcome:
+ """The shape returned from `create_invite`.
+
+ `raw_token` is what the admin endpoint puts in the outbound email
+ link; it never appears in storage. `invite_id` is the surrogate
+ key for the admin's "invites I've sent" listing. `invited_user_id`
+ is the freshly-provisioned `users` row id so the admin surface can
+ join through to the user-management page.
+ """
+ raw_token: str
+ invite_id: int
+ invited_user_id: int
+
+
+def create_invite(
+ *,
+ email: str,
+ first_name: str,
+ last_name: str,
+ role: str,
+ custom_message: str,
+ created_by_admin_id: int,
+) -> CreateOutcome:
+ """Provision the invitee `users` row + the `user_invite_tokens` row.
+
+ Caller (`api_admin.py`) is responsible for the admin-only auth check,
+ the self-email refusal (422), and the duplicate-email refusal (409).
+ This function trusts what it's handed and writes both rows
+ transactionally — the v0.10.0 `passcode.py` / v0.11.0 `device_trust.py`
+ helpers follow the same separation-of-concerns pattern.
+
+ The invitee `users` row is provisioned with:
+ * `permission_state='granted'` — the admin's hand is the grant;
+ the v0.8.0 self-serve `pending` queue is for the other path.
+ * `last_seen_at = NULL` — the discriminator for "invited but
+ not yet arrived" per the §16 / roadmap design. Every sign-in
+ path stamps `last_seen_at` to now, so a NULL value means the
+ invited user has not clicked through yet.
+ * `gitea_id = NULL`, `gitea_login = NULL` — same as a v0.7.0
+ OTC-provisioned user; the OAuth identity is grandfathered if
+ the user ever lands through that path.
+ * `display_name` defaults to "" (or local-part of
+ email if both are empty) so the user-management page reads a
+ sensible label before the user has signed in.
+ * `first_name` / `last_name` / `beta_request_reason` — the
+ first two from the admin's typed values; reason stays blank
+ (this user did not self-request access).
+ """
+ email_clean = email.strip()
+ first_clean = (first_name or "").strip()
+ last_clean = (last_name or "").strip()
+ display = " ".join(p for p in (first_clean, last_clean) if p).strip()
+ if not display:
+ display = email_clean.split("@", 1)[0] or email_clean
+
+ # 1. Provision the invitee users row. The grant is the admin's
+ # hand; no permission_events row is necessary for the grant itself
+ # (we are not transitioning from pending → granted, we are landing
+ # a fresh row directly into granted).
+ #
+ # Note on the "pending invite" discriminator: the brief floated
+ # `first_sign_in_at NULL` / `last_seen_at NULL` as the marker the
+ # admin user-management page reads off the row to render the
+ # "(pending invite)" badge. The schema didn't cooperate — the
+ # existing `users.last_seen_at` column is NOT NULL with a
+ # `datetime('now')` default (see `migrations/001_users_and_audit.sql`),
+ # and there is no `first_sign_in_at` column. Rather than introduce
+ # a schema migration to add one (the brief explicitly said "likely
+ # no `users` table changes"), the discriminator is the existence of
+ # an active row in `user_invite_tokens` joined on `invited_user_id`.
+ # The admin listing's `pending_invite` field joins through that
+ # table; the claim flow stamps `claimed_at` on the invite row,
+ # which clears the badge naturally. This shape keeps the
+ # discriminator scoped to the v0.17.0 surface and avoids
+ # double-tracking against an existing column.
+ cur = db.conn().execute(
+ """
+ INSERT INTO users (
+ gitea_id, gitea_login, email, display_name, avatar_url,
+ role, permission_state, first_name, last_name
+ )
+ VALUES (NULL, NULL, ?, ?, '', ?, 'granted', ?, ?)
+ """,
+ (email_clean, display, role, first_clean, last_clean),
+ )
+ invited_user_id = cur.lastrowid
+
+ # 2. Mint the token, hash it, write the invite row.
+ raw = _new_token()
+ h = _hash(raw)
+ cur = db.conn().execute(
+ f"""
+ INSERT INTO user_invite_tokens (
+ email, role, first_name, last_name, custom_message,
+ token_hash, expires_at, created_by_admin_id, invited_user_id
+ )
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now', '+{INVITE_TOKEN_TTL_DAYS} days'), ?, ?)
+ """,
+ (
+ email_clean,
+ role,
+ first_clean,
+ last_clean,
+ (custom_message or "").strip(),
+ h,
+ created_by_admin_id,
+ invited_user_id,
+ ),
+ )
+ invite_id = cur.lastrowid
+ return CreateOutcome(
+ raw_token=raw,
+ invite_id=invite_id,
+ invited_user_id=invited_user_id,
+ )
+
+
+# ---------------------------------------------------------------------------
+# Claim
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class ClaimOutcome:
+ """The result of `claim`.
+
+ `user` is populated only on success. `reason` distinguishes the
+ failure modes so the endpoint can return distinct HTTP statuses
+ (HTTP 410 for expired/claimed — the token is dead; HTTP 400 for
+ unknown/invalid — the request shape is wrong).
+ """
+ ok: bool
+ user: SessionUser | None
+ reason: str # 'ok' | 'invalid' | 'unknown' | 'expired' | 'claimed'
+ invite_id: int | None = None
+
+
+def claim(raw_token: str) -> ClaimOutcome:
+ """Validate the presented token and consume it.
+
+ Walks the active invite rows looking for a bcrypt hash match.
+ Mirrors `device_trust.lookup`: bcrypt's per-row salt means we
+ cannot SELECT by hash, but the set is small (a deployment's
+ outstanding invites at any moment) and bcrypt is cheap on the
+ order of milliseconds.
+
+ On a hit:
+ * Mark the row claimed (stamp `claimed_at = now`,
+ `claimed_by_user_id = invited_user_id` — the admin's
+ pre-provisioned row is the claimant).
+ * Stamp `last_seen_at = now` on the user row so the v0.9.0
+ admin user-management page no longer shows "(pending invite)".
+ * Return a populated `SessionUser` for the endpoint to sign in.
+
+ On a miss:
+ * `unknown` — no row matched. The token may have been forged or
+ the invite was admin-revoked.
+ * `expired` — row matched but `expires_at` is in the past.
+ * `claimed` — row matched but `claimed_at` is non-NULL. The
+ token was already consumed; the user must contact the admin
+ for a fresh invite.
+ * `invalid` — the token string itself was empty or unparseable.
+ """
+ raw = (raw_token or "").strip()
+ if not raw:
+ return ClaimOutcome(ok=False, user=None, reason="invalid")
+
+ rows = db.conn().execute(
+ """
+ SELECT id, token_hash, expires_at, claimed_at, invited_user_id, role
+ FROM user_invite_tokens
+ ORDER BY id DESC
+ """
+ ).fetchall()
+
+ matched = None
+ for row in rows:
+ if _check(raw, row["token_hash"]):
+ matched = row
+ break
+
+ if matched is None:
+ return ClaimOutcome(ok=False, user=None, reason="unknown")
+
+ if matched["claimed_at"] is not None:
+ return ClaimOutcome(
+ ok=False, user=None, reason="claimed", invite_id=matched["id"],
+ )
+
+ expired = db.conn().execute(
+ "SELECT datetime(?) < datetime('now') AS expired",
+ (matched["expires_at"],),
+ ).fetchone()["expired"]
+ if expired:
+ return ClaimOutcome(
+ ok=False, user=None, reason="expired", invite_id=matched["id"],
+ )
+
+ # Consume the row before signing in so a parallel claim of the same
+ # token cannot double-sign-in. (Mirrors `otc.verify_code`'s consume-
+ # before-provision shape.)
+ db.conn().execute(
+ """
+ UPDATE user_invite_tokens
+ SET claimed_at = datetime('now'),
+ claimed_by_user_id = invited_user_id
+ WHERE id = ?
+ """,
+ (matched["id"],),
+ )
+ # Stamp last_seen_at on the user row so the user's activity stamp
+ # is current after the claim (mirroring otc.verify_code's
+ # last-seen update on the provision path). The "(pending invite)"
+ # badge's clear is driven by the invite row's `claimed_at`
+ # transition above; this update is for the general user-listing's
+ # recency ordering.
+ db.conn().execute(
+ "UPDATE users SET last_seen_at = datetime('now') WHERE id = ?",
+ (matched["invited_user_id"],),
+ )
+
+ user_row = db.conn().execute(
+ """
+ SELECT id, gitea_id, gitea_login, email, display_name, avatar_url,
+ role, permission_state
+ FROM users
+ WHERE id = ?
+ """,
+ (matched["invited_user_id"],),
+ ).fetchone()
+ if user_row is None:
+ # The invitee user row was deleted between create_invite and
+ # claim (shouldn't happen under the FK ON DELETE CASCADE — the
+ # cascade would drop the invite row too — be defensive anyway).
+ return ClaimOutcome(
+ ok=False, user=None, reason="unknown", invite_id=matched["id"],
+ )
+
+ return ClaimOutcome(
+ ok=True,
+ user=SessionUser(
+ user_id=user_row["id"],
+ gitea_id=user_row["gitea_id"] or 0,
+ gitea_login=user_row["gitea_login"] or "",
+ display_name=user_row["display_name"],
+ email=user_row["email"] or "",
+ avatar_url=user_row["avatar_url"] or "",
+ role=user_row["role"],
+ permission_state=user_row["permission_state"] or "granted",
+ ),
+ reason="ok",
+ invite_id=matched["id"],
+ )
+
+
+# ---------------------------------------------------------------------------
+# List pending invites — for the admin's review surface
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class PendingInviteRow:
+ """The shape the `GET /api/admin/users/invites` endpoint returns.
+
+ Note the absence of `token_hash` — the hash is structurally private,
+ and the surface has no use for it. The raw token is also not on
+ the listing; it lives only in the email link.
+ """
+ id: int
+ email: str
+ role: str
+ first_name: str
+ last_name: str
+ custom_message: str
+ created_at: str
+ expires_at: str
+ created_by_admin_id: int
+ invited_user_id: int
+
+
+def list_pending_invites() -> list[PendingInviteRow]:
+ """Active invites (not claimed, not expired), freshest first.
+
+ The admin's "I sent these but they haven't been claimed yet" view.
+ Filters mirror the `device_trust.list_for_user` shape: the surface
+ only shows live records the framework would actually accept on a
+ presented token.
+ """
+ rows = db.conn().execute(
+ """
+ SELECT id, email, role, first_name, last_name, custom_message,
+ created_at, expires_at, created_by_admin_id, invited_user_id
+ FROM user_invite_tokens
+ WHERE claimed_at IS NULL
+ AND datetime(expires_at) > datetime('now')
+ ORDER BY created_at DESC, id DESC
+ """
+ ).fetchall()
+ return [
+ PendingInviteRow(
+ id=row["id"],
+ email=row["email"],
+ role=row["role"],
+ first_name=row["first_name"] or "",
+ last_name=row["last_name"] or "",
+ custom_message=row["custom_message"] or "",
+ created_at=row["created_at"],
+ expires_at=row["expires_at"],
+ created_by_admin_id=row["created_by_admin_id"],
+ invited_user_id=row["invited_user_id"],
+ )
+ for row in rows
+ ]
diff --git a/backend/app/main.py b/backend/app/main.py
index 33e6422..faa0a7d 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -24,6 +24,7 @@ from . import (
digest,
email_otc,
hygiene,
+ invites as invites_mod,
otc,
passcode as passcode_mod,
providers as providers_mod,
@@ -72,6 +73,25 @@ class PasscodeVerifyBody(BaseModel):
trust_device: bool = False
+class InviteClaimBody(BaseModel):
+ """v0.17.0 / roadmap item #16 — claim an admin-issued invite token.
+
+ The frontend `/invites/claim?token=…` page reads the token from
+ the URL and POSTs it here. The body bound matches the
+ `secrets.token_urlsafe(32)` output shape (~43 URL-safe chars);
+ the upper bound stays generous in case `TOKEN_BYTES` is ever
+ raised. The token-shape is opaque to this layer — `invites.claim`
+ bcrypt-checks it against the active candidate set.
+ """
+ token: str = Field(min_length=1, max_length=512)
+ # v0.11.0-style opt-in: the claim flow's "trust this device" gesture
+ # is bundled here so the invitee can land trusted on first sign-in
+ # without an extra roundtrip. Defaults to false so the gesture is
+ # explicit (the frontend modal renders a checkbox alongside the
+ # claim CTA).
+ trust_device: bool = False
+
+
@asynccontextmanager
async def lifespan(app: FastAPI):
config = load_config()
@@ -382,6 +402,95 @@ def _oauth_router(config) -> APIRouter:
},
}
+ # ---------------------------------------------------------------
+ # v0.17.0: admin-create user + invite claim (§6.1, roadmap item #16).
+ #
+ # The admin-create surface lives at POST /api/admin/users (see
+ # api_admin.py); this endpoint is the corresponding claim path the
+ # invitee hits when they click the link in their invite email.
+ # The frontend route `/invites/claim?token=…` reads the token from
+ # the URL and POSTs it here.
+ #
+ # The claim itself is the first-sign-in for the invitee: clicking
+ # the unique token in the email is proof of email control per the
+ # roadmap, so this endpoint skips the OTC step entirely on first
+ # sign-in. The session cookie lands; the response tells the
+ # frontend whether to route to passcode-set (if v0.10.0 passcode
+ # flow is in play and the user has not yet set a passcode) or to
+ # home.
+ #
+ # The endpoint is anonymous-reachable: the entire point is to
+ # establish the session, so we do not gate it on `require_user`.
+ # The trust-device opt-in mirrors the v0.11.0 OTC/passcode verify
+ # contract (the body's `trust_device` flag, when true, mints a
+ # fresh device-trust row on the same response so the invitee
+ # lands trusted on their first device).
+ # ---------------------------------------------------------------
+
+ @router.post("/api/invites/claim")
+ async def invites_claim(body: InviteClaimBody, request: Request, response: Response):
+ result = invites_mod.claim(body.token)
+ if result.reason == "expired":
+ # The token's TTL window passed without a claim. HTTP 410
+ # (Gone) so the frontend can render a "this invite has
+ # expired — please contact the admin for a fresh one"
+ # message distinct from the generic invalid-token shape.
+ raise HTTPException(410, "This invite has expired")
+ if result.reason == "claimed":
+ # The token was already consumed. HTTP 410 for the same
+ # reason — the row is dead either way.
+ raise HTTPException(410, "This invite has already been claimed")
+ if not result.ok or result.user is None:
+ # 'unknown' / 'invalid' — the token does not match any
+ # active invite row. HTTP 400 so it reads distinct from
+ # the dead-token shape above.
+ raise HTTPException(400, "Invalid invite token")
+
+ # Establish the session. From here on the invitee is signed
+ # in as the pre-provisioned user row carrying their
+ # pre-assigned role.
+ auth.store_session(request, result.user)
+
+ # v0.11.0 — opt-in device trust on the claim response. Same
+ # contract as OTC/passcode verify: when the body's flag is
+ # true, the server mints a fresh device-trust row and sets
+ # the long-lived cookie, so the invitee skips the email step
+ # on subsequent visits to the same browser.
+ if body.trust_device:
+ ua = request.headers.get("user-agent", "")
+ outcome = device_trust_mod.issue(result.user.user_id, ua)
+ _set_device_trust_cookie(response, outcome.raw_token)
+
+ # Has the user already set a passcode? (Could only happen via
+ # an admin pre-population path that doesn't exist yet, but
+ # the response shape mirrors `/api/auth/me` so the frontend
+ # can read it without a second call.) If `needs_passcode` is
+ # true and v0.10.0 passcode flow is in play, the frontend
+ # routes to /settings/notifications#sign-in to set a passcode
+ # immediately; otherwise it routes to /.
+ row = db.conn().execute(
+ "SELECT passcode_hash FROM users WHERE id = ?",
+ (result.user.user_id,),
+ ).fetchone()
+ has_passcode = bool(row and row["passcode_hash"])
+
+ return {
+ "ok": True,
+ "user": {
+ "id": result.user.user_id,
+ "display_name": result.user.display_name,
+ "email": result.user.email,
+ "role": result.user.role,
+ "permission_state": result.user.permission_state,
+ },
+ # Roadmap §16: the claim flow skips OTC entirely; the
+ # natural next step is passcode-set (so the invitee can
+ # sign back in without needing an email roundtrip on their
+ # second visit). The frontend uses this hint to decide
+ # whether to route to the passcode-set screen or to home.
+ "needs_passcode": not has_passcode,
+ }
+
# ---------------------------------------------------------------
# v0.11.0: trust device for 30 days (§6.2, roadmap item #9).
#
diff --git a/backend/migrations/019_user_invite_tokens.sql b/backend/migrations/019_user_invite_tokens.sql
new file mode 100644
index 0000000..06b3421
--- /dev/null
+++ b/backend/migrations/019_user_invite_tokens.sql
@@ -0,0 +1,105 @@
+-- §6.1 / v0.17.0: admin-create user with role + invite email (roadmap item #16).
+--
+-- Distinguishes from the v0.8.0 / v0.9.0 self-serve beta-access shape:
+-- here an *admin* creates a `users` row *before* the invited person has
+-- ever signed in, assigns them a role at creation time, and sends them
+-- an invite email carrying a claim link. The invitee clicks the link,
+-- the claim flow consumes the token (which is itself proof of email
+-- control), the row is marked claimed, and the user is signed in
+-- inheriting the pre-set role.
+--
+-- Migration slot 019 is allocated to this release. Slot 018 is reserved
+-- for the parallel #12 release (per-RFC invitation, owner-only) shipping
+-- in the same wave; the two features live in distinct tables
+-- (`user_invite_tokens` here vs. `rfc_invitations` there) so they
+-- coexist cleanly. Slot 016 was reserved+skipped by Session K during
+-- v0.9.0 integration; slot 017 is the v0.11.0 device-trust table.
+--
+-- Open-question decisions settled in this release (see CHANGELOG):
+-- * No `users` table changes — the brief floated `first_sign_in_at`
+-- / `last_seen_at IS NULL` as the "(pending invite)" discriminator,
+-- but the existing `users.last_seen_at` is NOT NULL with a
+-- `datetime('now')` default (migrations/001) and there is no
+-- `first_sign_in_at` column. Rather than land a schema migration to
+-- introduce one, the discriminator is the existence of an active
+-- (not-claimed, not-expired) row in `user_invite_tokens` joined on
+-- `invited_user_id`. The admin user-listing carries a
+-- `pending_invite` field populated via that join; on claim, the
+-- invite row's `claimed_at` populates and the badge clears.
+-- No new `permission_state` value is introduced either.
+-- * The token is opaque (random URL-safe string, bcrypt-hashed at
+-- rest), not a JWT, so admin revocation by row UPDATE works
+-- without distributing a key-rotation gesture.
+-- * The TTL is a constant (`INVITE_TOKEN_TTL_DAYS = 7` in
+-- `backend/app/invites.py`); env-var configurability is a follow-up.
+-- * Immediate-send (no admin-review-then-send queue) ships in this
+-- release; admin-preview is a future enhancement.
+-- * Bulk-invite (CSV paste) is deferred to a follow-up release;
+-- v0.17.0 is one-at-a-time.
+--
+-- Storage shape:
+--
+-- * `id` — surrogate key. Lets the admin "pending invites" listing
+-- address a row without leaking the token shape.
+-- * `email` — the address the invite was sent to (case-insensitive
+-- match at claim time, persisted verbatim for the audit trail).
+-- * `role` — the role the invitee inherits on first sign-in. Pinned
+-- via CHECK to the same set the §6.1 role flip accepts
+-- (`owner` / `admin` / `contributor`) so a future role-set drift
+-- fails loudly at insert rather than provisioning a ghost role.
+-- * `first_name` / `last_name` — captured at create time so the
+-- invitee skips the v0.8.0 capture-form step on first sign-in.
+-- * `custom_message` — optional free-text from the admin (max 500
+-- chars enforced at the API layer); embedded verbatim in the
+-- email body if present.
+-- * `token_hash` — bcrypt hash of the random opaque token. The
+-- raw token only ever lives in the outbound email link and the
+-- inbound claim body; server-side storage is the hash.
+-- * `expires_at` — `created_at + 7 days` (default at the app layer
+-- via `INVITE_TOKEN_TTL_DAYS`). A row past this stamp is dead;
+-- the claim path refuses with HTTP 410.
+-- * `created_at` — when the admin issued the invite.
+-- * `created_by_admin_id` — FK into users(id) for the admin who
+-- created the invite (no cascade; if the admin's row is deleted
+-- the invite history stays so the audit trail survives).
+-- * `claimed_at` — non-NULL once the invitee successfully claims.
+-- A second claim attempt against an already-claimed row returns
+-- HTTP 410.
+-- * `claimed_by_user_id` — FK into users(id) for the user row
+-- that consumed the token. In the common case this equals the
+-- freshly-provisioned row that was created at invite time; the
+-- FK lets the admin's "claimed" list join through.
+-- * `invited_user_id` — FK into users(id) for the pre-provisioned
+-- row. Created at invite time with `last_seen_at IS NULL` so the
+-- v0.9.0 admin user-management page can render a "(pending
+-- invite)" badge alongside existing users.
+--
+-- Indexing:
+-- * Unique index on `token_hash` documents the no-collision
+-- invariant (256 bits of CSPRNG entropy; collision is
+-- structurally impossible, the unique constraint catches a
+-- bug at insert time).
+-- * Index on `(email, claimed_at)` so the "is this email already
+-- invited?" pre-check the admin endpoint runs is a covering walk.
+-- * Index on `(created_by_admin_id, created_at DESC)` for the
+-- admin's "invites I've sent" listing.
+
+CREATE TABLE user_invite_tokens (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ email TEXT NOT NULL,
+ role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'contributor')),
+ first_name TEXT NOT NULL DEFAULT '',
+ last_name TEXT NOT NULL DEFAULT '',
+ custom_message TEXT NOT NULL DEFAULT '',
+ token_hash TEXT NOT NULL,
+ expires_at TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ created_by_admin_id INTEGER NOT NULL REFERENCES users(id),
+ claimed_at TEXT,
+ claimed_by_user_id INTEGER REFERENCES users(id),
+ invited_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE
+);
+
+CREATE UNIQUE INDEX idx_user_invite_tokens_hash ON user_invite_tokens (token_hash);
+CREATE INDEX idx_user_invite_tokens_email ON user_invite_tokens (email, claimed_at);
+CREATE INDEX idx_user_invite_tokens_admin ON user_invite_tokens (created_by_admin_id, created_at DESC);
diff --git a/backend/tests/test_admin_create_user_invite_vertical.py b/backend/tests/test_admin_create_user_invite_vertical.py
new file mode 100644
index 0000000..a160f2e
--- /dev/null
+++ b/backend/tests/test_admin_create_user_invite_vertical.py
@@ -0,0 +1,649 @@
+"""End-to-end integration tests for v0.17.0's admin-create user +
+invite-email + claim-flow vertical (roadmap item #16, §6.1).
+
+The release lands three halves of the same surface:
+
+ * **Admin-create user** at `POST /api/admin/users`. The admin types
+ email, first/last name, role, and an optional custom message. The
+ framework provisions the invitee `users` row (granted, with the
+ chosen role) and writes a `user_invite_tokens` row carrying the
+ bcrypt-hashed opaque token. The "pending invite" discriminator is
+ the active `user_invite_tokens` row joined on `invited_user_id`,
+ not a NULL column on `users` (the existing `last_seen_at` column
+ is NOT NULL). An invite email dispatches via the existing SMTP
+ relay.
+
+ * **Pending-invite admin listing** at `GET /api/admin/users/invites`.
+ Lists active (not claimed, not expired) invites for the admin's
+ "I sent these but they haven't been claimed yet" view.
+
+ * **Claim** at `POST /api/invites/claim`. The invitee POSTs the token
+ they got via email; the framework verifies, marks the row claimed,
+ signs them in (skipping OTC on first sign-in per the roadmap), and
+ returns a `needs_passcode` hint for the frontend to route to the
+ passcode-set screen.
+
+The tests prove:
+
+ * The happy path: admin creates → invite row + email envelope land →
+ invitee claims with the token → session is established.
+ * Non-admin caller is refused 403.
+ * Self-invite is refused 422.
+ * Duplicate email is refused 409.
+ * Owner-grant by non-owner is refused 422.
+ * Malformed role is refused 422 (pydantic regex).
+ * Custom message over 500 chars is refused 422 (pydantic max_length).
+ * Claim with valid token: signs in + marks row claimed.
+ * Claim with expired token: HTTP 410.
+ * Claim with already-claimed token: HTTP 410.
+ * Claim with unknown token: HTTP 400.
+ * The admin-create gesture writes a `permission_events` row with
+ event_kind='user_invited'.
+ * The user listing surfaces the `pending_invite` field for invited-
+ but-not-yet-claimed users, and clears it after claim.
+"""
+from __future__ import annotations
+
+import json
+
+from test_propose_vertical import ( # noqa: F401 — fixtures land via import
+ FakeGitea,
+ app_with_fake_gitea,
+ provision_user_row,
+ sign_in_as,
+ tmp_env,
+)
+
+
+def _reset_outbound():
+ from app import email as email_mod
+ email_mod.reset_sent_envelopes()
+
+
+def _outbound_invite_envelopes(to_address: str | None = None) -> list[dict]:
+ """Pull the invite-kind envelopes off the shared notifier buffer.
+
+ Mirrors the OTC code-extraction helper in
+ test_admin_users_vertical.py — invite emails land in the same
+ `_SENT` buffer with `kind='invite'`.
+ """
+ from app import email as email_mod
+ out = []
+ for env in email_mod.sent_envelopes():
+ if env.get("kind") != "invite":
+ continue
+ if to_address is not None and env["to"] != to_address:
+ continue
+ out.append(env)
+ return out
+
+
+def _extract_claim_url(envelope: dict) -> str:
+ """Pull the claim URL out of the invite email body."""
+ for line in envelope["body"].splitlines():
+ line = line.strip()
+ if line.startswith("http") and "/invites/claim" in line:
+ return line
+ raise AssertionError(f"no claim URL in envelope body: {envelope['body']!r}")
+
+
+def _extract_claim_token(envelope: dict) -> str:
+ """Pull the `token` query-string param out of the claim URL."""
+ from urllib.parse import urlparse, parse_qs
+ url = _extract_claim_url(envelope)
+ qs = parse_qs(urlparse(url).query)
+ return qs["token"][0]
+
+
+# ---------------------------------------------------------------------------
+# Admin create + invite — happy path
+# ---------------------------------------------------------------------------
+
+
+def test_admin_create_user_invite_happy_path(app_with_fake_gitea):
+ """Admin creates → user row + invite-token row + email envelope all
+ land; the response carries the created ids and the inviter is the
+ admin who issued the gesture."""
+ from fastapi.testclient import TestClient
+ from app import db
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=100, login="adminzero", role="admin")
+ sign_in_as(
+ client, user_id=100, gitea_login="adminzero",
+ display_name="Admin Zero", role="admin",
+ email="adminzero@test",
+ )
+ _reset_outbound()
+
+ r = client.post(
+ "/api/admin/users",
+ json={
+ "email": "invitee@example.com",
+ "first_name": "Inv",
+ "last_name": "Tee",
+ "role": "contributor",
+ "custom_message": "We chatted at the conference — welcome!",
+ },
+ )
+ assert r.status_code == 200, r.text
+ body = r.json()
+ assert body["ok"] is True
+ assert body["email"] == "invitee@example.com"
+ assert body["role"] == "contributor"
+ assert body["invite_id"] > 0
+ assert body["invited_user_id"] > 0
+
+ # User row exists with the chosen role + granted. The "pending
+ # invite" discriminator is the active `user_invite_tokens` row,
+ # not a NULL column on `users` — see the invites.create_invite
+ # docstring for the reasoning.
+ row = db.conn().execute(
+ "SELECT role, permission_state, first_name, last_name "
+ "FROM users WHERE email = ? COLLATE NOCASE",
+ ("invitee@example.com",),
+ ).fetchone()
+ assert row is not None
+ assert row["role"] == "contributor"
+ assert row["permission_state"] == "granted"
+ assert row["first_name"] == "Inv"
+ assert row["last_name"] == "Tee"
+
+ # Invite-token row exists with the matching ids and the custom
+ # message persisted verbatim.
+ invite = db.conn().execute(
+ "SELECT email, role, custom_message, created_by_admin_id, "
+ "invited_user_id, claimed_at FROM user_invite_tokens WHERE id = ?",
+ (body["invite_id"],),
+ ).fetchone()
+ assert invite is not None
+ assert invite["email"] == "invitee@example.com"
+ assert invite["role"] == "contributor"
+ assert invite["custom_message"] == "We chatted at the conference — welcome!"
+ assert invite["created_by_admin_id"] == 100
+ assert invite["invited_user_id"] == body["invited_user_id"]
+ assert invite["claimed_at"] is None
+
+ # Email envelope landed with the invite kind and embeds the
+ # custom message + claim URL. The inviter display name comes
+ # off the DB row (which provision_user_row sets to
+ # login.capitalize()), not the sign_in_as cookie payload.
+ envelopes = _outbound_invite_envelopes(to_address="invitee@example.com")
+ assert len(envelopes) == 1
+ env = envelopes[0]
+ assert "Adminzero" in env["subject"] or "Adminzero" in env["body"]
+ assert "We chatted at the conference — welcome!" in env["body"]
+ # Claim URL is well-formed.
+ url = _extract_claim_url(env)
+ assert "/invites/claim?token=" in url
+
+ # `permission_events` row landed with event_kind='user_invited'.
+ ev = db.conn().execute(
+ "SELECT actor_user_id, subject_user_id, event_kind, details "
+ "FROM permission_events WHERE event_kind = 'user_invited'"
+ ).fetchall()
+ assert len(ev) == 1
+ assert ev[0]["actor_user_id"] == 100
+ assert ev[0]["subject_user_id"] == body["invited_user_id"]
+ details = json.loads(ev[0]["details"])
+ assert details["email"] == "invitee@example.com"
+ assert details["role"] == "contributor"
+
+
+# ---------------------------------------------------------------------------
+# Refusals on the admin-create endpoint
+# ---------------------------------------------------------------------------
+
+
+def test_admin_create_user_invite_refuses_non_admin(app_with_fake_gitea):
+ """A contributor caller is refused 403; an anonymous caller 401."""
+ from fastapi.testclient import TestClient
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=110, login="contrib", role="contributor")
+ sign_in_as(
+ client, user_id=110, gitea_login="contrib",
+ display_name="Contrib", role="contributor",
+ )
+ r = client.post(
+ "/api/admin/users",
+ json={"email": "x@y.com", "role": "contributor"},
+ )
+ assert r.status_code == 403, r.text
+
+ client.cookies.clear()
+ r = client.post(
+ "/api/admin/users",
+ json={"email": "x@y.com", "role": "contributor"},
+ )
+ assert r.status_code == 401
+
+
+def test_admin_create_user_invite_refuses_self_email(app_with_fake_gitea):
+ """An admin trying to invite their own email is refused 422 —
+ self-invite is the wrong channel; the role-change endpoint exists
+ for self-edits."""
+ from fastapi.testclient import TestClient
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=120, login="adm", role="admin")
+ # Manually set the admin's email since provision_user_row's
+ # fixture uses login@test; this is what we'll try to self-invite.
+ from app import db
+ db.conn().execute(
+ "UPDATE users SET email = ? WHERE id = ?",
+ ("selfinviter@example.com", 120),
+ )
+ sign_in_as(
+ client, user_id=120, gitea_login="adm",
+ display_name="Adm", role="admin",
+ email="selfinviter@example.com",
+ )
+ r = client.post(
+ "/api/admin/users",
+ json={
+ "email": "selfinviter@example.com",
+ "role": "contributor",
+ },
+ )
+ assert r.status_code == 422, r.text
+ assert "yourself" in r.json()["detail"].lower()
+
+
+def test_admin_create_user_invite_refuses_duplicate_email(app_with_fake_gitea):
+ """An admin trying to invite an email that already maps to a users
+ row is refused 409 — the existing role / grant gestures are the
+ right surface for an existing user."""
+ from fastapi.testclient import TestClient
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=130, login="adminD", role="admin")
+ provision_user_row(user_id=131, login="existingone", role="contributor")
+ sign_in_as(
+ client, user_id=130, gitea_login="adminD",
+ display_name="Admin D", role="admin",
+ )
+ # provision_user_row sets email to @test, so:
+ r = client.post(
+ "/api/admin/users",
+ json={
+ "email": "existingone@test",
+ "role": "contributor",
+ },
+ )
+ assert r.status_code == 409, r.text
+
+
+def test_admin_create_user_invite_owner_grant_refused_for_non_owner(app_with_fake_gitea):
+ """An admin (not owner) trying to invite a fresh user as `owner` is
+ refused 422 — §6.1's owner-zero is the only bootstrap path."""
+ from fastapi.testclient import TestClient
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=140, login="adminNoOwner", role="admin")
+ sign_in_as(
+ client, user_id=140, gitea_login="adminNoOwner",
+ display_name="Admin", role="admin",
+ )
+ r = client.post(
+ "/api/admin/users",
+ json={
+ "email": "wouldbeowner@example.com",
+ "role": "owner",
+ },
+ )
+ assert r.status_code == 422, r.text
+
+
+def test_admin_create_user_invite_owner_can_invite_as_owner(app_with_fake_gitea):
+ """A sitting owner can invite a fresh user as `owner` — the §6.1
+ role-grant channel. Sanity check that the owner-grant path itself
+ works, paired with the refusal above."""
+ from fastapi.testclient import TestClient
+ from app import db
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=150, login="ownerzero", role="owner")
+ sign_in_as(
+ client, user_id=150, gitea_login="ownerzero",
+ display_name="Owner Zero", role="owner",
+ )
+ r = client.post(
+ "/api/admin/users",
+ json={
+ "email": "newowner@example.com",
+ "role": "owner",
+ },
+ )
+ assert r.status_code == 200, r.text
+ row = db.conn().execute(
+ "SELECT role FROM users WHERE email = ? COLLATE NOCASE",
+ ("newowner@example.com",),
+ ).fetchone()
+ assert row["role"] == "owner"
+
+
+def test_admin_create_user_invite_refuses_malformed_role(app_with_fake_gitea):
+ """The pydantic regex refuses any role outside the §6.1 set."""
+ from fastapi.testclient import TestClient
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=160, login="adminR", role="admin")
+ sign_in_as(
+ client, user_id=160, gitea_login="adminR",
+ display_name="Admin R", role="admin",
+ )
+ r = client.post(
+ "/api/admin/users",
+ json={
+ "email": "ok@example.com",
+ "role": "superuser",
+ },
+ )
+ assert r.status_code == 422
+
+
+def test_admin_create_user_invite_refuses_long_custom_message(app_with_fake_gitea):
+ """Custom message over the 500-char ceiling is refused 422."""
+ from fastapi.testclient import TestClient
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=170, login="adminM", role="admin")
+ sign_in_as(
+ client, user_id=170, gitea_login="adminM",
+ display_name="Admin M", role="admin",
+ )
+ r = client.post(
+ "/api/admin/users",
+ json={
+ "email": "ok@example.com",
+ "role": "contributor",
+ "custom_message": "x" * 501,
+ },
+ )
+ assert r.status_code == 422
+
+
+# ---------------------------------------------------------------------------
+# Claim flow
+# ---------------------------------------------------------------------------
+
+
+def test_claim_with_valid_token_signs_in_and_marks_claimed(app_with_fake_gitea):
+ """End-to-end: admin creates → invitee posts the token to
+ /api/invites/claim → session lands + row marked claimed +
+ last_seen_at stamps on the user row (the pending-invite
+ discriminator clears)."""
+ from fastapi.testclient import TestClient
+ from app import db
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=200, login="adminC", role="admin")
+ sign_in_as(
+ client, user_id=200, gitea_login="adminC",
+ display_name="Admin C", role="admin",
+ )
+ _reset_outbound()
+
+ r = client.post(
+ "/api/admin/users",
+ json={
+ "email": "claimant@example.com",
+ "first_name": "Clai",
+ "last_name": "Mant",
+ "role": "contributor",
+ },
+ )
+ assert r.status_code == 200
+ invite_id = r.json()["invite_id"]
+ invited_user_id = r.json()["invited_user_id"]
+
+ env = _outbound_invite_envelopes("claimant@example.com")[0]
+ token = _extract_claim_token(env)
+
+ # The invitee's request is anonymous (they have no session
+ # yet). We clear the admin's session cookie to simulate this.
+ client.cookies.clear()
+
+ r = client.post(
+ "/api/invites/claim",
+ json={"token": token},
+ )
+ assert r.status_code == 200, r.text
+ body = r.json()
+ assert body["ok"] is True
+ assert body["user"]["id"] == invited_user_id
+ assert body["user"]["role"] == "contributor"
+ assert body["user"]["permission_state"] == "granted"
+ # The user has no passcode set yet → frontend should route to
+ # passcode-set per the roadmap.
+ assert body["needs_passcode"] is True
+
+ # Row marked claimed; last_seen_at populated.
+ invite = db.conn().execute(
+ "SELECT claimed_at, claimed_by_user_id FROM user_invite_tokens "
+ "WHERE id = ?",
+ (invite_id,),
+ ).fetchone()
+ assert invite["claimed_at"] is not None
+ assert invite["claimed_by_user_id"] == invited_user_id
+
+ user_row = db.conn().execute(
+ "SELECT last_seen_at FROM users WHERE id = ?",
+ (invited_user_id,),
+ ).fetchone()
+ assert user_row["last_seen_at"] is not None
+
+
+def test_claim_with_expired_token_returns_410(app_with_fake_gitea):
+ """A token whose `expires_at` has passed surfaces as HTTP 410."""
+ from fastapi.testclient import TestClient
+ from app import db, invites
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=210, login="adminE", role="admin")
+ sign_in_as(
+ client, user_id=210, gitea_login="adminE",
+ display_name="Admin E", role="admin",
+ )
+ _reset_outbound()
+
+ # Create the invite, then back-date the expires_at to the past.
+ r = client.post(
+ "/api/admin/users",
+ json={
+ "email": "expired@example.com",
+ "role": "contributor",
+ },
+ )
+ assert r.status_code == 200
+ invite_id = r.json()["invite_id"]
+ db.conn().execute(
+ "UPDATE user_invite_tokens SET expires_at = datetime('now', '-1 day') "
+ "WHERE id = ?",
+ (invite_id,),
+ )
+ env = _outbound_invite_envelopes("expired@example.com")[0]
+ token = _extract_claim_token(env)
+
+ client.cookies.clear()
+ r = client.post("/api/invites/claim", json={"token": token})
+ assert r.status_code == 410, r.text
+ assert "expired" in r.json()["detail"].lower()
+
+
+def test_claim_with_already_claimed_token_returns_410(app_with_fake_gitea):
+ """Re-claiming an already-consumed token surfaces as HTTP 410."""
+ from fastapi.testclient import TestClient
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=220, login="adminA", role="admin")
+ sign_in_as(
+ client, user_id=220, gitea_login="adminA",
+ display_name="Admin A", role="admin",
+ )
+ _reset_outbound()
+
+ r = client.post(
+ "/api/admin/users",
+ json={
+ "email": "twice@example.com",
+ "role": "contributor",
+ },
+ )
+ assert r.status_code == 200
+ env = _outbound_invite_envelopes("twice@example.com")[0]
+ token = _extract_claim_token(env)
+
+ client.cookies.clear()
+ # First claim succeeds.
+ r = client.post("/api/invites/claim", json={"token": token})
+ assert r.status_code == 200
+ # Second claim, with the same token, refuses with 410.
+ client.cookies.clear()
+ r = client.post("/api/invites/claim", json={"token": token})
+ assert r.status_code == 410, r.text
+ assert "already" in r.json()["detail"].lower()
+
+
+def test_claim_with_unknown_token_returns_400(app_with_fake_gitea):
+ """A token that doesn't match any active invite is HTTP 400."""
+ from fastapi.testclient import TestClient
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ # No invite ever created; the token is whatever the attacker
+ # types in. The endpoint should refuse without disclosing
+ # whether the token "looked" right.
+ r = client.post(
+ "/api/invites/claim",
+ json={"token": "totally-made-up-token-string-that-is-not-real"},
+ )
+ assert r.status_code == 400, r.text
+
+
+# ---------------------------------------------------------------------------
+# Pending-invite admin listing
+# ---------------------------------------------------------------------------
+
+
+def test_pending_invites_listing_shows_active_invites_only(app_with_fake_gitea):
+ """The `GET /api/admin/users/invites` listing filters to active
+ invites — claimed and expired rows do not surface here (the admin
+ user-listing carries the per-row pending-invite badge for the
+ living rows; once claimed, the badge clears)."""
+ from fastapi.testclient import TestClient
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=300, login="adminL", role="admin")
+ sign_in_as(
+ client, user_id=300, gitea_login="adminL",
+ display_name="Admin L", role="admin",
+ )
+ _reset_outbound()
+
+ # Create three invites: one stays pending, one we'll claim, one
+ # we'll back-date to expired.
+ for email in ("alive@ex.co", "claimed@ex.co", "expired@ex.co"):
+ r = client.post(
+ "/api/admin/users",
+ json={"email": email, "role": "contributor"},
+ )
+ assert r.status_code == 200
+
+ # Claim the middle one.
+ env = _outbound_invite_envelopes("claimed@ex.co")[0]
+ token_claim = _extract_claim_token(env)
+
+ # Expire the third one.
+ from app import db
+ db.conn().execute(
+ "UPDATE user_invite_tokens SET expires_at = datetime('now', '-1 day') "
+ "WHERE email = 'expired@ex.co'"
+ )
+
+ # The admin's session is still on the cookie. Claim works
+ # anonymously; we clear and restore.
+ admin_cookie = client.cookies.get("rfc_session")
+ client.cookies.clear()
+ r = client.post("/api/invites/claim", json={"token": token_claim})
+ assert r.status_code == 200
+ client.cookies.set("rfc_session", admin_cookie)
+
+ r = client.get("/api/admin/users/invites")
+ assert r.status_code == 200, r.text
+ items = r.json()["items"]
+ emails = sorted(i["email"] for i in items)
+ assert emails == ["alive@ex.co"]
+
+
+def test_pending_invite_badge_clears_after_claim(app_with_fake_gitea):
+ """The `/api/admin/users` listing surfaces `pending_invite` while
+ the invite is unclaimed; after the invitee claims, the row's
+ pending_invite is null."""
+ from fastapi.testclient import TestClient
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=310, login="adminB", role="admin")
+ sign_in_as(
+ client, user_id=310, gitea_login="adminB",
+ display_name="Admin B", role="admin",
+ )
+ _reset_outbound()
+
+ r = client.post(
+ "/api/admin/users",
+ json={"email": "badgey@ex.co", "role": "contributor"},
+ )
+ assert r.status_code == 200
+ invited_id = r.json()["invited_user_id"]
+
+ # Before claim — pending_invite is populated.
+ r = client.get("/api/admin/users")
+ assert r.status_code == 200
+ row = next(u for u in r.json()["items"] if u["id"] == invited_id)
+ assert row["pending_invite"] is not None
+ assert row["pending_invite"]["invite_id"] > 0
+
+ # Claim.
+ env = _outbound_invite_envelopes("badgey@ex.co")[0]
+ token = _extract_claim_token(env)
+ admin_cookie = client.cookies.get("rfc_session")
+ client.cookies.clear()
+ r = client.post("/api/invites/claim", json={"token": token})
+ assert r.status_code == 200
+ client.cookies.set("rfc_session", admin_cookie)
+
+ # After claim — pending_invite is null.
+ r = client.get("/api/admin/users")
+ assert r.status_code == 200
+ row = next(u for u in r.json()["items"] if u["id"] == invited_id)
+ assert row["pending_invite"] is None
+
+
+def test_pending_invites_listing_admin_only(app_with_fake_gitea):
+ """The listing requires admin/owner; contributor gets 403."""
+ from fastapi.testclient import TestClient
+
+ app, _fake = app_with_fake_gitea
+ with TestClient(app) as client:
+ provision_user_row(user_id=320, login="contribL", role="contributor")
+ sign_in_as(
+ client, user_id=320, gitea_login="contribL",
+ display_name="Contrib L", role="contributor",
+ )
+ r = client.get("/api/admin/users/invites")
+ assert r.status_code == 403
diff --git a/frontend/package.json b/frontend/package.json
index 69fda27..6e83feb 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
- "version": "0.12.0",
+ "version": "0.17.0",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 247f955..edf59aa 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -14,6 +14,7 @@ import Philosophy from './components/Philosophy.jsx'
import Docs from './components/Docs.jsx'
import NotificationSettings from './components/NotificationSettings.jsx'
import Admin from './components/Admin.jsx'
+import InviteClaim from './components/InviteClaim.jsx'
import ToastHost, { showToast } from './components/ToastHost.jsx'
import CookieConsentBanner from './components/CookieConsentBanner.jsx'
import Privacy from './pages/Privacy.jsx'
@@ -156,6 +157,10 @@ export default function App() {
} />
} />
} />
+ {/* v0.17.0 — roadmap item #16. The claim landing page for
+ admin-issued invites. Anonymous-reachable; the call
+ itself establishes the session on success. */}
+ } />
} />
} />
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
diff --git a/frontend/src/api.js b/frontend/src/api.js
index 2dd1e79..2e903fd 100644
--- a/frontend/src/api.js
+++ b/frontend/src/api.js
@@ -757,6 +757,53 @@ export async function removeAllowlistEmail(email) {
}))
}
+// v0.17.0 — roadmap item #16. Admin-create user + invite email with
+// optional custom message. The frontend modal on /admin/users wires
+// these two helpers; the claim helper drives the /invites/claim page
+// that the invitee lands on when they click the email link.
+//
+// `createUserInvite` returns `{ ok, invite_id, invited_user_id, email,
+// role }`. The 409 path (duplicate email) and 422 path (self-invite,
+// owner-grant-by-non-owner, malformed input) surface as thrown errors
+// via `jsonOrThrow` so the modal can render the server's message.
+//
+// `listUserInvites` returns the active-invites list for the admin's
+// "I sent these but they haven't been claimed yet" view. Active means
+// not claimed and not expired; once the invitee clicks through, the
+// row clears here and the user-listing's `pending_invite` badge
+// vanishes alongside.
+
+export async function createUserInvite({ email, first_name, last_name, role, custom_message }) {
+ return jsonOrThrow(await fetch('/api/admin/users', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ email,
+ first_name: first_name || '',
+ last_name: last_name || '',
+ role,
+ custom_message: custom_message || '',
+ }),
+ }))
+}
+
+export async function listUserInvites() {
+ return jsonOrThrow(await fetch('/api/admin/users/invites'))
+}
+
+// Claim an admin-issued invite token. Anonymous endpoint — the invitee
+// is not yet signed in; this call establishes the session on success.
+// `trustDevice` mirrors the v0.11.0 OTC/passcode opt-in: when true,
+// the server mints a fresh device-trust row + sets the long-lived
+// cookie so the invitee skips OTC on their next visit.
+export async function claimInvite(token, { trustDevice = false } = {}) {
+ return jsonOrThrow(await fetch('/api/invites/claim', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token, trust_device: !!trustDevice }),
+ }))
+}
+
export async function searchUsers(q) {
const params = new URLSearchParams()
if (q) params.set('q', q)
diff --git a/frontend/src/components/Admin.jsx b/frontend/src/components/Admin.jsx
index 0f85c13..9b0deba 100644
--- a/frontend/src/components/Admin.jsx
+++ b/frontend/src/components/Admin.jsx
@@ -23,8 +23,15 @@ import {
listAllowlist,
addAllowlistEmail,
removeAllowlistEmail,
+ createUserInvite,
} from '../api.js'
+// v0.17.0 — roadmap item #16. The max length the backend enforces
+// (Pydantic body bound + `invites.CUSTOM_MESSAGE_MAX_LENGTH`); kept
+// here so the modal's "remaining chars" counter stays in lockstep
+// with the server-side bound.
+const CUSTOM_MESSAGE_MAX_LENGTH = 500
+
const TABS = [
{ path: 'users', label: 'Users' },
{ path: 'allowlist', label: 'Allowlist' },
@@ -89,6 +96,10 @@ function UsersTab() {
const [busy, setBusy] = useState({})
const [error, setError] = useState(null)
const [stateFilter, setStateFilter] = useState('all')
+ // v0.17.0 — roadmap item #16. The "Create user + invite" modal's
+ // open/closed state. The modal is local to UsersTab (it only opens
+ // from the header button) and refreshes the listing on success.
+ const [inviteModalOpen, setInviteModalOpen] = useState(false)
async function refresh() {
setError(null)
@@ -172,8 +183,28 @@ function UsersTab() {
retain their v0.7.0 semantics — promote to admin to remove a
user's ability to write without silencing them.
+ {/* v0.17.0 — roadmap item #16. The "Create user + invite"
+ affordance opens a modal that provisions a fresh users row
+ with the chosen role and sends an invite email with a
+ single-use claim link. */}
+
{STATE_CHIPS.map(chip => (
@@ -224,12 +255,26 @@ function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }
const state = u.permission_state || 'granted'
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
const handle = u.gitea_login ? `@${u.gitea_login}` : (u.email || u.display_name)
+ // v0.17.0 — roadmap item #16. The user's row may also be the
+ // "(pending invite)" shape: admin-created via POST /api/admin/users,
+ // not yet claimed via /api/invites/claim. The backend's user-listing
+ // surfaces this via `pending_invite` (object with invite_id +
+ // expires_at) or null. The badge sits inline next to the handle so
+ // the admin sees at a glance which rows are real users vs. unclaimed
+ // invites.
+ const pendingInvite = u.pending_invite
return (
<>
{handle}
+ {pendingInvite && (
+ (pending invite)
+ )}
{fullName || u.display_name}
{u.email ? ` · ${u.email}` : ''}
@@ -319,6 +364,162 @@ function PermissionCell({ user: u, busy, onFlipPermission }) {
)
}
+// ── Create user + invite modal (v0.17.0 / roadmap item #16) ────────────────
+//
+// The "Create user + invite" affordance on the Users tab opens this
+// modal. Admin types email, first name, last name, role, and (optionally)
+// a custom message to embed in the invite email. On submit, calls
+// `POST /api/admin/users` which provisions the row + sends the email.
+// The 409 path (duplicate email) and 422 path (self-invite, owner-
+// grant-by-non-owner, malformed input) surface the server's message
+// inline; the success path closes the modal and refreshes the listing.
+//
+// The modal lives in this file rather than a separate component
+// because it has one caller (UsersTab), reuses the existing modal
+// stylesheet from /admin's chrome, and shares the
+// CUSTOM_MESSAGE_MAX_LENGTH constant defined at the top of the file.
+
+function CreateUserInviteModal({ onClose, onSuccess }) {
+ const [email, setEmail] = useState('')
+ const [firstName, setFirstName] = useState('')
+ const [lastName, setLastName] = useState('')
+ const [role, setRole] = useState('contributor')
+ const [customMessage, setCustomMessage] = useState('')
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState(null)
+ const [success, setSuccess] = useState(null)
+
+ const remaining = CUSTOM_MESSAGE_MAX_LENGTH - customMessage.length
+
+ async function handleSubmit(event) {
+ event.preventDefault()
+ const trimmedEmail = email.trim()
+ if (!trimmedEmail) {
+ setError('Email is required')
+ return
+ }
+ setBusy(true)
+ setError(null)
+ setSuccess(null)
+ try {
+ const result = await createUserInvite({
+ email: trimmedEmail,
+ first_name: firstName.trim(),
+ last_name: lastName.trim(),
+ role,
+ custom_message: customMessage,
+ })
+ setSuccess(`Invite sent to ${result.email} (${result.role}).`)
+ // Brief delay so the admin sees the success state, then close
+ // and let the parent refresh the listing.
+ setTimeout(() => { onSuccess?.() }, 600)
+ } catch (e) {
+ setError(e.message || 'Unable to send invite')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+
e.stopPropagation()}>
+
+
Create user + invite
+
+
+
+ Provisions a fresh user row with the chosen role and sends an
+ invite email carrying a single-use claim link. The link
+ expires in 7 days. The invitee clicks through to claim
+ their account — no OTC roundtrip is required on first sign-in.
+
+
+
+
+ )
+}
+
// ── Private-beta allowlist (`migrations/011_allowlist.sql`) ────────────────
function AllowlistTab() {
diff --git a/frontend/src/components/InviteClaim.jsx b/frontend/src/components/InviteClaim.jsx
new file mode 100644
index 0000000..3596e5e
--- /dev/null
+++ b/frontend/src/components/InviteClaim.jsx
@@ -0,0 +1,144 @@
+// v0.17.0 — roadmap item #16. The claim flow's landing page.
+//
+// The admin's invite email carries a link to /invites/claim?token=…;
+// the invitee clicks through and lands here. The page reads the
+// token from the URL, posts it to /api/invites/claim, and on success
+// routes either to the passcode-set screen (if v0.10.0 passcode flow
+// is in play and the user has no passcode yet) or to home.
+//
+// Anonymous-reachable: the entire point of the call is to establish
+// the session; we do not pre-check authentication.
+//
+// Failure modes the backend distinguishes:
+// * 410 — token is expired or already claimed (the row is dead).
+// * 400 — token doesn't match any active invite (forged, revoked,
+// or wiped).
+//
+// We surface both as the same "this invite link isn't valid" shape
+// for the invitee — the detail message from the server reads
+// distinctively enough that the admin can debug from logs, and the
+// invitee just needs to know they should contact the admin for a
+// fresh link.
+
+import { useEffect, useState } from 'react'
+import { useLocation, useNavigate } from 'react-router-dom'
+import { claimInvite } from '../api.js'
+
+export default function InviteClaim() {
+ const location = useLocation()
+ const navigate = useNavigate()
+ const [status, setStatus] = useState('working') // 'working' | 'ok' | 'failed'
+ const [error, setError] = useState(null)
+ const [trustDevice, setTrustDevice] = useState(false)
+ const [submitted, setSubmitted] = useState(false)
+ const [user, setUser] = useState(null)
+ const [needsPasscode, setNeedsPasscode] = useState(false)
+
+ const params = new URLSearchParams(location.search)
+ const token = params.get('token') || ''
+
+ async function performClaim() {
+ if (!token) {
+ setStatus('failed')
+ setError('No invite token in the URL.')
+ return
+ }
+ setSubmitted(true)
+ setStatus('working')
+ setError(null)
+ try {
+ const result = await claimInvite(token, { trustDevice })
+ setUser(result.user)
+ setNeedsPasscode(!!result.needs_passcode)
+ setStatus('ok')
+ } catch (e) {
+ setStatus('failed')
+ setError(e.message || 'Unable to claim invite')
+ }
+ }
+
+ // Pre-flight: if the URL has no token at all, fail fast so the
+ // invitee sees the missing-token shape immediately rather than
+ // an empty form.
+ useEffect(() => {
+ if (!token) {
+ setStatus('failed')
+ setError('This claim link is missing its token.')
+ }
+ }, [token])
+
+ // On a successful claim, route the user onward. The brief calls
+ // this out: route to passcode-set if v0.10.0 passcode flow is in
+ // play and the user has no passcode yet; otherwise route to home.
+ useEffect(() => {
+ if (status !== 'ok') return
+ const timeout = setTimeout(() => {
+ if (needsPasscode) {
+ navigate('/settings/notifications#sign-in', { replace: true })
+ } else {
+ navigate('/', { replace: true })
+ }
+ }, 1200)
+ return () => clearTimeout(timeout)
+ }, [status, needsPasscode, navigate])
+
+ return (
+
+ You've been invited to this deployment. Click the button below
+ to claim your account and sign in. This link is single-use and
+ expires 7 days after it was sent.
+
+ Welcome{user?.display_name ? `, ${user.display_name}` : ''}!
+ You're signed in.
+
+
+ {needsPasscode
+ ? 'Redirecting you to set a passcode so you can sign in without an email roundtrip next time…'
+ : 'Redirecting you to the home page…'}
+
+ >
+ )}
+ {status === 'failed' && (
+ <>
+
+ {error || "This invite link isn't valid."}
+
+
+ If you believe this is a mistake, contact the admin who
+ sent you the invite — they can issue a fresh link.
+