Files
rfc-app/frontend/src/components/Admin.jsx
T
Ben Stull 999c4b65ef §22 M3 frontend: /p/<project>/ routing, runtime branding, directory, 308s (v0.35.0)
Implements the M3-frontend slice of the §22 multi-project track, per
docs/superpowers/specs/2026-06-03-m3-frontend-design.md (design merged in
#10). Completes the runtime-config cut 0.33.0 (M3-backend Plan A) began.

Frontend:
- DeploymentProvider boots GET /api/deployment → {name, tagline,
  defaultProjectId, projects}; brandTitle() neutral 'RFC' pre-fetch fallback.
- /p/:projectId/* routing with generic /e/<slug> segment. ProjectLayout
  fetches /api/projects/:id, applies per-project theme (reset on switch),
  provides ProjectContext, guards the corpus (served only for the default;
  others get NotServedPlaceholder — decouples this slice from Plan B).
- Directory at / (2+ projects) with N=1 redirect into the single project;
  ProjectSwitcher in deployment chrome; entry-noun by project type.
- VITE_APP_NAME hard cut: removed from vite.config + index.html; the 6 brand
  reads now use deployment.name via context; static <title>RFC</title> + JS
  document.title. Internal /rfc·/proposals links → /p/<project>/e|proposals
  via lib/entryPaths.

Backend:
- GET /api/deployment returns default_project_id (the guard contract).
- Server-side 308s: /rfc/<slug>, /rfc/<slug>/pr/<n>, /proposals/<n> →
  /p/<default>/… . nginx (testing + prod) routes /rfc/ and /proposals/ to
  the backend.

Tests: 3 new backend redirect/deployment tests (438 pass); Vitest unit for
DeploymentProvider, ProjectLayout (theme/guard/404), Directory (11 pass);
clean build with no VITE_APP_NAME. Playwright e2e deferred until Tier-1 seeds
a registry (see CHANGELOG 0.35.0 step 5).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 05:58:59 -07:00

1018 lines
33 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// /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 (
<div className="admin-page">
<nav className="admin-rail">
<h2>Admin</h2>
<ul>
{tabs.map(t => (
<li key={t.path}>
<NavLink
to={t.path}
className={({ isActive }) => `admin-rail-link ${isActive ? 'active' : ''}`}
>
{t.label}
</NavLink>
</li>
))}
</ul>
<p className="admin-rail-note">
Signed in as <strong>{viewer.display_name}</strong> ({viewer.role}).
You can <Link to="/">return to the catalog</Link> at any time.
</p>
</nav>
<div className="admin-content">
<Routes>
<Route index element={<UsersTab />} />
<Route path="users" element={<UsersTab />} />
<Route path="allowlist" element={<AllowlistTab />} />
<Route path="graduation" element={<GraduationTab />} />
{isSiteOwner && <Route path="retired" element={<RetiredTab />} />}
<Route path="audit" element={<AuditTab />} />
<Route path="permissions" element={<PermissionsTab />} />
</Routes>
</div>
</div>
)
}
// ── 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 <p className="muted">Loading users</p>
const filtered = stateFilter === 'all'
? users
: users.filter(u => (u.permission_state || 'granted') === stateFilter)
return (
<div className="admin-tab">
<header className="admin-tab-header">
<div className="admin-tab-heading">
<h2>Users</h2>
{/* 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>
</div>
<p className="muted">
The pending bucket is the beta-access review queue (§6.1 /
v0.8.0). Grant or revoke writes to <code>permission_events</code>
and stamps <code>permission_decided_by</code> +{' '}
<code>permission_decided_at</code>. 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.
</p>
</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 => (
<button
key={chip.value}
type="button"
className={`admin-chip${stateFilter === chip.value ? ' active' : ''}`}
onClick={() => setStateFilter(chip.value)}
>
{chip.label} <span className="admin-chip-count">{counts[chip.value] ?? 0}</span>
</button>
))}
</div>
{filtered.length === 0 ? (
<p className="muted">No users in this bucket.</p>
) : (
<table className="admin-table admin-users-table">
<thead>
<tr>
<th>User</th>
<th>State</th>
<th>Role</th>
<th>Write-muted</th>
<th>Signed up</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{filtered.map(u => (
<UserRow
key={u.id}
user={u}
busy={!!busy[u.id]}
onChangeRole={role => changeRole(u.id, role)}
onToggleMute={muted => toggleMute(u.id, muted)}
onFlipPermission={state => flipPermission(u.id, state)}
/>
))}
</tbody>
</table>
)}
</div>
)
}
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 (
<>
<tr>
<td>
<div className="user-cell">
<div className="user-cell-handle">
<span className="user-handle">{handle}</span>
{pendingInvite && (
<span
className="invite-badge"
title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
>pending invite</span>
)}
</div>
<span className="muted">
{fullName || u.display_name}
{showEmail ? ` · ${u.email}` : ''}
</span>
</div>
</td>
<td>
<PermissionCell user={u} busy={busy} onFlipPermission={onFlipPermission} />
</td>
<td>
<select
value={u.role}
onChange={e => onChangeRole(e.target.value)}
disabled={busy}
>
<option value="contributor">Contributor</option>
<option value="admin">Admin</option>
<option value="owner">Owner</option>
</select>
</td>
<td>
{u.role === 'contributor' ? (
<label className="mute-toggle">
<input
type="checkbox"
checked={!!u.muted}
onChange={e => onToggleMute(e.target.checked)}
disabled={busy}
/>
{u.muted ? 'Muted' : 'Active'}
</label>
) : (
<span className="muted">N/A</span>
)}
</td>
<TimeCell value={u.created_at} />
{/* 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. */}
<TimeCell
value={pendingInvite ? null : u.last_seen_at}
emptyLabel={pendingInvite ? 'Never' : '—'}
/>
</tr>
{state === 'pending' && u.beta_request_reason ? (
<tr className="user-row-reason">
<td colSpan={6}>
<div className="user-reason-block">
<strong>Why they want access:</strong>
<p>{u.beta_request_reason}</p>
</div>
</td>
</tr>
) : 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 <td className="muted">{emptyLabel}</td>
const [date, ...rest] = String(value).split(' ')
const time = rest.join(' ')
return (
<td className="user-when">
<span className="user-when-date">{date}</span>
{time && <span className="user-when-time muted">{time}</span>}
</td>
)
}
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 (
<div className="permission-cell">
<span className={`permission-badge permission-badge-${state}`}>{state}</span>
<div className="permission-actions">
{state !== 'granted' && (
<button
type="button"
className="btn-link-quiet"
disabled={busy}
onClick={() => onFlipPermission('granted')}
>Grant</button>
)}
{state === 'granted' && (
<button
type="button"
className="btn-link-quiet"
disabled={busy}
onClick={() => {
if (confirm(`Revoke access for ${u.display_name || u.email}?`)) {
onFlipPermission('revoked')
}
}}
>Revoke</button>
)}
</div>
{decidedSuffix && (
<div className="permission-decided muted">{decidedSuffix.replace(/^ · /, '')}</div>
)}
</div>
)
}
// ── 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 (
<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() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [draftEmail, setDraftEmail] = useState('')
const [draftNote, setDraftNote] = useState('')
const [busy, setBusy] = useState(false)
async function refresh() {
setError(null)
try {
setData(await listAllowlist())
} catch (e) {
setError(e.message)
}
}
useEffect(() => { refresh() }, [])
async function handleAdd(event) {
event.preventDefault()
const email = draftEmail.trim()
if (!email) return
setBusy(true); setError(null)
try {
await addAllowlistEmail(email, draftNote.trim() || null)
setDraftEmail(''); setDraftNote('')
await refresh()
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
async function handleRemove(email) {
if (!confirm(`Remove ${email} from the allowlist?`)) return
setBusy(true); setError(null)
try {
await removeAllowlistEmail(email)
await refresh()
} catch (e) {
setError(e.message)
} finally {
setBusy(false)
}
}
if (data == null && !error) return <p className="muted">Loading allowlist</p>
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Allowlist</h2>
<p className="muted">
When this list has any rows, OAuth sign-in is restricted: only emails
here (case-insensitive) may sign in. Already-provisioned users are
grandfathered by their Gitea ID and never re-checked. An empty list
turns the gate off entirely.
</p>
<p className="muted">
Status:{' '}
<strong>{data?.active ? 'Private beta — gate active' : 'Open — anyone can sign in'}</strong>
</p>
</header>
{error && <p className="settings-note warning">{error}</p>}
<form className="allowlist-add" onSubmit={handleAdd}>
<input
type="email"
placeholder="email@example.com"
value={draftEmail}
onChange={e => setDraftEmail(e.target.value)}
required
disabled={busy}
/>
<input
type="text"
placeholder="Note (optional)"
value={draftNote}
onChange={e => setDraftNote(e.target.value)}
maxLength={200}
disabled={busy}
/>
<button type="submit" className="btn-primary" disabled={busy || !draftEmail.trim()}>
Add to allowlist
</button>
</form>
{data?.items?.length > 0 ? (
<table className="admin-table">
<thead>
<tr>
<th>Email</th>
<th>Note</th>
<th>Added by</th>
<th>Added at</th>
<th></th>
</tr>
</thead>
<tbody>
{data.items.map(r => (
<tr key={r.email}>
<td><code>{r.email}</code></td>
<td>{r.note || <span className="muted"></span>}</td>
<td>
{r.added_by_login
? <span>@{r.added_by_login}</span>
: <span className="muted"></span>}
</td>
<td className="muted">{r.created_at}</td>
<td>
<button
type="button"
className="btn-link-quiet"
onClick={() => handleRemove(r.email)}
disabled={busy}
>Remove</button>
</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="muted">
No allow-listed emails yet. Add the first one to put the deployment
into private-beta mode.
</p>
)}
</div>
)
}
// ── Graduation-readiness queue (§13.2) ─────────────────────────────────────
function GraduationTab() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const pid = useProjectId()
useEffect(() => {
listGraduationQueue()
.then(setData)
.catch(e => setError(e.message))
}, [])
if (error) return <p className="settings-note warning">{error}</p>
if (data == null) return <p className="muted">Loading queue</p>
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Graduation queue</h2>
<p className="muted">
Super-drafts with owners claimed and zero blocking body-edit PRs.
Open one to run the §13.3 graduation sequence.
</p>
</header>
<h3 className="admin-section-h">Ready ({data.ready.length})</h3>
{data.ready.length === 0 && (
<p className="muted">No super-drafts ready right now.</p>
)}
<ul className="grad-queue">
{data.ready.map(item => (
<li key={item.slug}>
<Link to={entryPath(pid, item.slug)} className="grad-queue-link">
<strong>{item.title}</strong>
<span className="muted"> owners: {item.owners.join(', ')}</span>
</Link>
</li>
))}
</ul>
<h3 className="admin-section-h">Blocked ({data.blocked.length})</h3>
{data.blocked.length === 0 && (
<p className="muted">No blocked super-drafts.</p>
)}
<ul className="grad-queue">
{data.blocked.map(item => (
<li key={item.slug}>
<Link to={entryPath(pid, item.slug)} className="grad-queue-link">
<strong>{item.title}</strong>
<span className="muted">
{' — '}
{!item.owners_set && 'no owners yet'}
{!item.owners_set && item.blocking_prs > 0 && '; '}
{item.blocking_prs > 0 && `${item.blocking_prs} open body-edit PR${item.blocking_prs === 1 ? '' : 's'}`}
</span>
</Link>
</li>
))}
</ul>
</div>
)
}
// ── Retired (soft-deleted) entries — site-owner-only un-retire (§13.7) ─────
function RetiredTab() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
const [busy, setBusy] = useState({})
const load = () => {
listRetiredRFCs()
.then(setData)
.catch(e => setError(e.message))
}
useEffect(load, [])
const onUnretire = async (slug) => {
setBusy(b => ({ ...b, [slug]: true }))
setError(null)
try {
await unretireRFC(slug)
load()
} catch (e) {
setError(e.message)
} finally {
setBusy(b => ({ ...b, [slug]: false }))
}
}
if (error) return <p className="settings-note warning">{error}</p>
if (data == null) return <p className="muted">Loading retired entries</p>
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Retired</h2>
<p className="muted">
Soft-deleted RFCs (§13.7). They are hidden from the catalog and
every view; the entry stays in the meta repo. Un-retiring restores
an entry to the state it held before. Site owners only.
</p>
</header>
<h3 className="admin-section-h">Retired ({data.items.length})</h3>
{data.items.length === 0 && (
<p className="muted">No retired entries.</p>
)}
<ul className="grad-queue">
{data.items.map(item => (
<li key={item.slug}>
<span className="grad-queue-link">
<strong>{item.title}</strong>
<span className="muted">
{' — '}{item.id || item.slug}
{`; restores to ${item.restores_to}`}
</span>
</span>
<button
type="button"
className="btn-secondary"
disabled={!!busy[item.slug]}
onClick={() => onUnretire(item.slug)}
>
{busy[item.slug] ? 'Un-retiring…' : 'Un-retire'}
</button>
</li>
))}
</ul>
</div>
)
}
// ── Audit log (`actions`) — filter chips + paging ──────────────────────────
function AuditTab() {
const [data, setData] = useState(null)
const [filters, setFilters] = useState({ actionKind: '', actorUserId: '', rfcSlug: '' })
const [error, setError] = useState(null)
async function load(beforeId = null) {
setError(null)
try {
const r = await listAuditLog({
actionKind: filters.actionKind || undefined,
actorUserId: filters.actorUserId ? Number(filters.actorUserId) : undefined,
rfcSlug: filters.rfcSlug || undefined,
beforeId,
limit: 100,
})
setData(r)
} catch (e) {
setError(e.message)
}
}
useEffect(() => { load() }, [filters])
const kinds = useMemo(() => data?.action_kinds || [], [data])
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Audit log</h2>
<p className="muted">
Every bot-mediated write lands here. The most recent rows show first;
filter to narrow to one kind, one actor, or one RFC.
</p>
</header>
<div className="audit-filters">
<select
value={filters.actionKind}
onChange={e => setFilters(f => ({ ...f, actionKind: e.target.value }))}
>
<option value="">All action kinds</option>
{kinds.map(k => <option key={k} value={k}>{k}</option>)}
</select>
<input
type="text"
placeholder="RFC slug…"
value={filters.rfcSlug}
onChange={e => setFilters(f => ({ ...f, rfcSlug: e.target.value }))}
/>
<input
type="number"
placeholder="Actor user_id…"
value={filters.actorUserId}
onChange={e => setFilters(f => ({ ...f, actorUserId: e.target.value }))}
/>
</div>
{error && <p className="settings-note warning">{error}</p>}
{data == null && <p className="muted">Loading</p>}
{data?.items?.length === 0 && (
<p className="muted">No rows match this filter.</p>
)}
{data?.items?.length > 0 && (
<table className="admin-table audit-table">
<thead>
<tr>
<th>When</th>
<th>Action</th>
<th>Actor</th>
<th>On behalf of</th>
<th>RFC</th>
<th>PR / branch</th>
</tr>
</thead>
<tbody>
{data.items.map(row => (
<tr key={row.id}>
<td className="muted">{row.created_at}</td>
<td><code>{row.action_kind}</code></td>
<td>{row.actor_display || row.actor_login || '—'}</td>
<td>{row.on_behalf_of}</td>
<td>{row.rfc_slug || '—'}</td>
<td>
{row.pr_number != null && <span>#{row.pr_number} </span>}
{row.branch_name && <code>{row.branch_name}</code>}
</td>
</tr>
))}
</tbody>
</table>
)}
{data?.has_more && (
<button
className="btn-link-muted"
onClick={() => load(data.items[data.items.length - 1].id)}
>
Load older
</button>
)}
</div>
)
}
// ── Permission events (`permission_events`) ────────────────────────────────
function PermissionsTab() {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
listPermissionEvents({ limit: 100 })
.then(setData)
.catch(e => setError(e.message))
}, [])
if (error) return <p className="settings-note warning">{error}</p>
if (data == null) return <p className="muted">Loading</p>
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Permission events</h2>
<p className="muted">
Every role change and write-mute toggle. The companion to the
audit log, scoped to authorization changes.
</p>
</header>
{data.items.length === 0 && (
<p className="muted">No permission events yet.</p>
)}
{data.items.length > 0 && (
<table className="admin-table">
<thead>
<tr>
<th>When</th>
<th>Event</th>
<th>Actor</th>
<th>Subject</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{data.items.map(r => (
<tr key={r.id}>
<td className="muted">{r.created_at}</td>
<td><code>{r.event_kind}</code></td>
<td>{r.actor_display || r.actor_login || '—'}</td>
<td>{r.subject_display || r.subject_login || '—'}</td>
<td className="muted">
{r.details ? formatDetails(r.details) : ''}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
function formatDetails(details) {
if (!details || typeof details !== 'object') return String(details ?? '')
if (details.before != null && details.after != null) {
return `${String(details.before)}${String(details.after)}`
}
return JSON.stringify(details)
}