// /admin — the admin home base. // // Topics 12 and 13 both expanded the admin's repertoire without giving // it a centralized home. Slice 7 consolidates them: role management, // the §6.2 app-wide write-mute, the audit-log viewer, the // graduation-readiness queue, and a read of the permission-events log // — five thin sub-surfaces behind a left-rail menu. // // The page is admin-only; the App.jsx route mounts it only when the // viewer's role is owner or admin, and every /api/admin/* endpoint // guards independently. import { useEffect, useMemo, useState } from 'react' import { Routes, Route, NavLink, Link } from 'react-router-dom' import { listAdminUsers, setUserRole, setUserMute, setUserPermission, listAuditLog, listPermissionEvents, listGraduationQueue, listAllowlist, addAllowlistEmail, removeAllowlistEmail, createUserInvite, listRetiredRFCs, unretireRFC, } from '../api.js' import { EVENTS, track } from '../lib/analytics.js' import { entryPath, useProjectId } from '../lib/entryPaths' // 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' }, { path: 'graduation', label: 'Graduation queue' }, { path: 'audit', label: 'Audit log' }, { path: 'permissions', label: 'Permission events' }, ] export default function Admin({ viewer }) { // §13.7: the "Retired" surface (un-retire) is site-owner-only. The // backend gates /api/admin/retired-rfcs and /unretire on the owner role // regardless; we only show the link to owners so admins aren't offered // a tab that would 403. const isSiteOwner = viewer.role === 'owner' const tabs = isSiteOwner ? [...TABS, { path: 'retired', label: 'Retired' }] : TABS return (
} /> } /> } /> } /> {isSiteOwner && } />} } /> } />
) } // ── Users + role + write-mute + permission grant/revoke (§6.1 / §6.2) ────── // // v0.9.0 (roadmap item #7) lands the user-management surface. The table // shows every user with their permission_state, sign-up reason (when // pending), role, write-mute, and Grant / Revoke controls. State filter // chips above the table narrow to one bucket — the "Pending" chip is the // admin's daily inbox shape. const STATE_CHIPS = [ { value: 'all', label: 'All' }, { value: 'pending', label: 'Pending' }, { value: 'granted', label: 'Granted' }, { value: 'revoked', label: 'Revoked' }, ] function UsersTab() { const [users, setUsers] = useState(null) 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) try { const r = await listAdminUsers() setUsers(r.items || []) } catch (e) { setError(e.message) } } useEffect(() => { refresh() }, []) async function changeRole(userId, role) { setBusy(b => ({ ...b, [userId]: true })) setError(null) try { await setUserRole(userId, role) setUsers(prev => prev.map(u => u.id === userId ? { ...u, role } : u)) } catch (e) { setError(e.message) } finally { setBusy(b => ({ ...b, [userId]: false })) } } async function toggleMute(userId, muted) { setBusy(b => ({ ...b, [userId]: true })) setError(null) try { await setUserMute(userId, muted) setUsers(prev => prev.map(u => u.id === userId ? { ...u, muted } : u)) } catch (e) { setError(e.message) } finally { setBusy(b => ({ ...b, [userId]: false })) } } async function flipPermission(userId, state) { setBusy(b => ({ ...b, [userId]: true })) setError(null) try { await setUserPermission(userId, state) // v0.15.0 — analytics: fire on a successful §6.1 grant/revoke. // action collapses the {pending → granted, revoked → granted} // edges onto `grant`, and `granted → revoked` onto `revoke`, // matching the roadmap's two-arm taxonomy. const action = state === 'granted' ? 'grant' : 'revoke' track(EVENTS.ADMIN_PERMISSION_DECISION, { action, target_user_id: String(userId) }) // Refresh the full row so permission_decided_{at,by_*} update too. await refresh() } catch (e) { setError(e.message) } finally { setBusy(b => ({ ...b, [userId]: false })) } } const counts = useMemo(() => { const c = { all: 0, pending: 0, granted: 0, revoked: 0 } if (users) { c.all = users.length for (const u of users) { const s = u.permission_state || 'granted' if (s in c) c[s] += 1 } } return c }, [users]) if (users == null) return

Loading users…

const filtered = stateFilter === 'all' ? users : users.filter(u => (u.permission_state || 'granted') === stateFilter) return (

Users

{/* 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. */}

The pending bucket is the beta-access review queue (§6.1 / v0.8.0). Grant or revoke writes to permission_events and stamps permission_decided_by +{' '} permission_decided_at. Role and write-mute controls retain their v0.7.0 semantics — promote to admin to remove a user's ability to write without silencing them.

{error &&

{error}

} {inviteModalOpen && ( setInviteModalOpen(false)} onSuccess={async () => { setInviteModalOpen(false) await refresh() }} /> )}
{STATE_CHIPS.map(chip => ( ))}
{filtered.length === 0 ? (

No users in this bucket.

) : ( {filtered.map(u => ( changeRole(u.id, role)} onToggleMute={muted => toggleMute(u.id, muted)} onFlipPermission={state => flipPermission(u.id, state)} /> ))}
User State Role Write-muted Signed up Last seen
)}
) } 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 // When there's no gitea_login the handle already IS the email, so the // subline would otherwise repeat it. Only append the email when it adds // something the handle doesn't already show. const showEmail = u.email && u.email !== handle return ( <>
{handle} {pendingInvite && ( pending invite )}
{fullName || u.display_name} {showEmail ? ` · ${u.email}` : ''}
{u.role === 'contributor' ? ( ) : ( N/A )} {/* An unclaimed admin invite has provably never authenticated, so last_seen_at is just the row-creation default (it equals created_at). Render the truth — "Never" — rather than a timestamp that reads like a real visit. */} {state === 'pending' && u.beta_request_reason ? (
Why they want access:

{u.beta_request_reason}

) : null} ) } // Render a "YYYY-MM-DD HH:MM:SS" timestamp as an intentional date-over-time // stack (date prominent, time quiet below) rather than letting a narrow // column wrap the value mid-string. Falls back to an em-dash when absent. function TimeCell({ value, emptyLabel = '—' }) { if (!value) return {emptyLabel} const [date, ...rest] = String(value).split(' ') const time = rest.join(' ') return ( {date} {time && {time}} ) } function PermissionCell({ user: u, busy, onFlipPermission }) { const state = u.permission_state || 'granted' const decidedSuffix = u.permission_decided_at ? ` · by ${u.permission_decided_by_login ? '@' + u.permission_decided_by_login : '—'} at ${u.permission_decided_at}` : '' return (
{state}
{state !== 'granted' && ( )} {state === 'granted' && ( )}
{decidedSuffix && (
{decidedSuffix.replace(/^ · /, '')}
)}
) } // ── 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, }) // v0.17.0 + #21 Part C — Amplitude wiring. target_user_id is // the OHM user id the invite-create gesture provisioned; // initial_role is what the invitee inherits on claim. // custom_message_chars is a coarse signal of admin effort // (0 = template-only, 1+ = personalized). No PII. track(EVENTS.USER_INVITED, { target_user_id: result.invited_user_id != null ? String(result.invited_user_id) : null, initial_role: result.role, custom_message_chars: (customMessage || '').length, }) 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 (
e.stopPropagation()}>

Create user + invite

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.