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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.12.0",
"version": "0.17.0",
"type": "module",
"scripts": {
"dev": "vite",
+5
View File
@@ -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() {
<Route path="/welcome" element={<Landing />} />
<Route path="/login" element={<Login />} />
<Route path="/beta-pending" element={<BetaPending viewer={viewer} />} />
{/* v0.17.0 — roadmap item #16. The claim landing page for
admin-issued invites. Anonymous-reachable; the call
itself establishes the session on success. */}
<Route path="/invites/claim" element={<InviteClaim />} />
<Route path="/philosophy" element={<PhilosophyWithSidebar viewer={viewer} />} />
<Route path="/docs" element={<DocsWithSidebar viewer={viewer} />} />
{/* §14.5 / §14.6: cookie-consent companions to /philosophy.
+47
View File
@@ -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)
+201
View File
@@ -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.
</p>
{/* 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. */}
<div className="admin-tab-actions">
<button
type="button"
className="btn-primary"
onClick={() => setInviteModalOpen(true)}
>Create user + invite</button>
</div>
</header>
{error && <p className="settings-note warning">{error}</p>}
{inviteModalOpen && (
<CreateUserInviteModal
onClose={() => setInviteModalOpen(false)}
onSuccess={async () => {
setInviteModalOpen(false)
await refresh()
}}
/>
)}
<div className="admin-filter-chips">
{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 (
<>
<tr>
<td>
<div className="user-cell">
<span className="user-handle">{handle}</span>
{pendingInvite && (
<span
className="invite-badge"
title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
>(pending invite)</span>
)}
<span className="muted">
{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 (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal-panel" onClick={e => e.stopPropagation()}>
<header className="modal-header">
<h3>Create user + invite</h3>
<button
type="button"
className="btn-link-quiet"
onClick={onClose}
disabled={busy}
aria-label="Close"
>×</button>
</header>
<p className="muted">
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.
</p>
<form onSubmit={handleSubmit} className="create-user-invite-form">
<label>
<span>Email</span>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
required
disabled={busy}
autoFocus
maxLength={320}
/>
</label>
<div className="form-row">
<label>
<span>First name</span>
<input
type="text"
value={firstName}
onChange={e => setFirstName(e.target.value)}
disabled={busy}
maxLength={120}
/>
</label>
<label>
<span>Last name</span>
<input
type="text"
value={lastName}
onChange={e => setLastName(e.target.value)}
disabled={busy}
maxLength={120}
/>
</label>
</div>
<label>
<span>Role</span>
<select
value={role}
onChange={e => setRole(e.target.value)}
disabled={busy}
>
<option value="contributor">Contributor</option>
<option value="admin">Admin</option>
<option value="owner">Owner (owner-only)</option>
</select>
</label>
<label>
<span>
Custom message (optional){' '}
<span className={`muted${remaining < 0 ? ' warning' : ''}`}>
{remaining} chars left
</span>
</span>
<textarea
value={customMessage}
onChange={e => setCustomMessage(e.target.value)}
disabled={busy}
rows={4}
maxLength={CUSTOM_MESSAGE_MAX_LENGTH}
placeholder="Optional — embedded in the invite email."
/>
</label>
{error && <p className="settings-note warning">{error}</p>}
{success && <p className="settings-note success">{success}</p>}
<div className="modal-actions">
<button type="button" onClick={onClose} disabled={busy}>Cancel</button>
<button
type="submit"
className="btn-primary"
disabled={busy || !email.trim() || remaining < 0}
>
{busy ? 'Sending…' : 'Send invite'}
</button>
</div>
</form>
</div>
</div>
)
}
// ── Private-beta allowlist (`migrations/011_allowlist.sql`) ────────────────
function AllowlistTab() {
+144
View File
@@ -0,0 +1,144 @@
// v0.17.0 — roadmap item #16. The claim flow's landing page.
//
// The admin's invite email carries a link to /invites/claim?token=…;
// the invitee clicks through and lands here. The page reads the
// token from the URL, posts it to /api/invites/claim, and on success
// routes either to the passcode-set screen (if v0.10.0 passcode flow
// is in play and the user has no passcode yet) or to home.
//
// Anonymous-reachable: the entire point of the call is to establish
// the session; we do not pre-check authentication.
//
// Failure modes the backend distinguishes:
// * 410 — token is expired or already claimed (the row is dead).
// * 400 — token doesn't match any active invite (forged, revoked,
// or wiped).
//
// We surface both as the same "this invite link isn't valid" shape
// for the invitee — the detail message from the server reads
// distinctively enough that the admin can debug from logs, and the
// invitee just needs to know they should contact the admin for a
// fresh link.
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { claimInvite } from '../api.js'
export default function InviteClaim() {
const location = useLocation()
const navigate = useNavigate()
const [status, setStatus] = useState('working') // 'working' | 'ok' | 'failed'
const [error, setError] = useState(null)
const [trustDevice, setTrustDevice] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [user, setUser] = useState(null)
const [needsPasscode, setNeedsPasscode] = useState(false)
const params = new URLSearchParams(location.search)
const token = params.get('token') || ''
async function performClaim() {
if (!token) {
setStatus('failed')
setError('No invite token in the URL.')
return
}
setSubmitted(true)
setStatus('working')
setError(null)
try {
const result = await claimInvite(token, { trustDevice })
setUser(result.user)
setNeedsPasscode(!!result.needs_passcode)
setStatus('ok')
} catch (e) {
setStatus('failed')
setError(e.message || 'Unable to claim invite')
}
}
// Pre-flight: if the URL has no token at all, fail fast so the
// invitee sees the missing-token shape immediately rather than
// an empty form.
useEffect(() => {
if (!token) {
setStatus('failed')
setError('This claim link is missing its token.')
}
}, [token])
// On a successful claim, route the user onward. The brief calls
// this out: route to passcode-set if v0.10.0 passcode flow is in
// play and the user has no passcode yet; otherwise route to home.
useEffect(() => {
if (status !== 'ok') return
const timeout = setTimeout(() => {
if (needsPasscode) {
navigate('/settings/notifications#sign-in', { replace: true })
} else {
navigate('/', { replace: true })
}
}, 1200)
return () => clearTimeout(timeout)
}, [status, needsPasscode, navigate])
return (
<div className="invite-claim-page">
<div className="invite-claim-panel">
<h1>Claim your account</h1>
{!submitted && status === 'working' && token && (
<>
<p>
You've been invited to this deployment. Click the button below
to claim your account and sign in. This link is single-use and
expires 7 days after it was sent.
</p>
<label className="claim-trust-toggle">
<input
type="checkbox"
checked={trustDevice}
onChange={e => setTrustDevice(e.target.checked)}
/>
{' '}Trust this device for 30 days (skip the email step on
your next visit from this browser).
</label>
<div className="claim-actions">
<button
type="button"
className="btn-primary"
onClick={performClaim}
>Claim my account</button>
</div>
</>
)}
{submitted && status === 'working' && (
<p>Claiming…</p>
)}
{status === 'ok' && (
<>
<p className="settings-note success">
Welcome{user?.display_name ? `, ${user.display_name}` : ''}!
You're signed in.
</p>
<p className="muted">
{needsPasscode
? 'Redirecting you to set a passcode so you can sign in without an email roundtrip next time…'
: 'Redirecting you to the home page…'}
</p>
</>
)}
{status === 'failed' && (
<>
<p className="settings-note warning">
{error || "This invite link isn't valid."}
</p>
<p className="muted">
If you believe this is a mistake, contact the admin who
sent you the invite they can issue a fresh link.
</p>
</>
)}
</div>
</div>
)
}