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. */} +
+ +
{error &&

{error}

} + {inviteModalOpen && ( + setInviteModalOpen(false)} + onSuccess={async () => { + setInviteModalOpen(false) + await refresh() + }} + /> + )}
{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. +

+
+ +
+ + +
+ +