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>