1456c8b73f
Wave 5. Roadmap item #16. Folds in #21 Part C Amplitude wiring inline.
From the v0.9.0 /admin/users surface, an admin can create a fresh
users row, assign a role (admin/owner/granted-beta-user), include
an optional custom message, and dispatch an invite email carrying
a single-use opaque claim token (256-bit CSPRNG, bcrypt-at-rest,
7-day TTL). The invitee clicks through, lands at /invites/claim,
optionally checks "trust this device for 30 days", and is signed
in without going through OTC (the token in the email is itself
proof of email control).
Backend: migration 019_user_invite_tokens.sql (auto-applied);
backend/app/invites.py (create + claim + list module);
backend/app/email_invite.py (sibling of email_otc); POST
/api/admin/users + GET /api/admin/users/invites in api_admin;
POST /api/invites/claim in main.py's oauth_router (shares
trust-device cookie helper with /auth/otc/verify). 15 new tests in
test_admin_create_user_invite_vertical.py. permission_events row
with event_kind='user_invited' for the audit trail.
Frontend: Admin.jsx Create-user-invite modal + (pending invite)
badge in UserRow; InviteClaim.jsx /invites/claim landing page.
App.jsx route registration. api.js helpers.
Design choices documented in the CHANGELOG: immediate-send (no
admin-review queue); no bulk-invite (deferred); OTC skipped on
first sign-in (token = proof of email control); no users table
changes (discriminator is active user_invite_tokens row, not a
new first_sign_in_at column); refusal cases (self-invite 422,
duplicate email 409, non-owner admin granting owner 422).
Amplitude wiring (inline, #21 Part C):
- USER_INVITED from CreateUserInviteModal { target_user_id,
initial_role, custom_message_chars }
- INVITE_CLAIMED from InviteClaim { invited_by_admin_id,
initial_role, needs_passcode, trust_device }
- identify() BEFORE the claim event with properties:
claim_method 'admin-invite', invited_at (setOnce),
invited_by_admin_id (setOnce), initial_role (setOnce) —
so the Amplitude user record is created with the OHM
user_id from the very first event the invitee fires
Subagent ο shipped the feature on feature/v0.17.0-admin-create-user
(41b0c6a). Driver-side integration squash-merged into main,
hand-resolved 5 files (VERSION, package.json, CHANGELOG —
strict-descending to 0.17.0 → 0.16.0 → 0.15.0; App.jsx — both
new routes kept; api_admin.py — both per-user additive fields
+ pending-invite query both kept). Added inline Amplitude wiring
in CreateUserInviteModal + InviteClaim. 252 backend tests pass
(33 new across #12 + #16 surfaces). Frontend build verified green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
106 lines
5.9 KiB
SQL
106 lines
5.9 KiB
SQL
-- §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);
|