Release 0.17.0: admin-create user + invite email (custom message; claim-link claim flow)

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) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-05-28 04:48:02 -07:00
parent b3f1b15f65
commit 41b0c6af99
13 changed files with 2268 additions and 4 deletions
+109
View File
@@ -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).
#