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:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rfc-app-frontend",
|
||||
"private": true,
|
||||
"version": "0.12.0",
|
||||
"version": "0.17.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user