Files
rfc-app/frontend/src/components/Admin.jsx
Ben Stull 21fcbc92d4 Release 0.3.0: private-beta gate + anonymous read mode
Adds an email allowlist (toggleable per deployment) that restricts
OAuth sign-in to listed emails while keeping read paths public.
Anonymous visitors now see the full app shell in read-only mode
instead of the §14.1 landing wall. Empty allowlist = gate off, so
deployments that don't enable it behave exactly as 0.2.3.

Also fixes single-finger scroll on /philosophy and other .chrome-pane
views on iOS Safari (.app: 100vh → 100dvh).

Renames deploy/nginx/rfc.wiggleverse.org.conf →
ohm.wiggleverse.org.conf to match the deployed-domain rename
(rfc.wiggleverse.org deprovisioned 2026-05-27).

See CHANGELOG.md for full details + upgrade steps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:58:58 -07:00

548 lines
17 KiB
React

// /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,
listAuditLog,
listPermissionEvents,
listGraduationQueue,
listAllowlist,
addAllowlistEmail,
removeAllowlistEmail,
} from '../api.js'
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 }) {
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 />} />
<Route path="audit" element={<AuditTab />} />
<Route path="permissions" element={<PermissionsTab />} />
</Routes>
</div>
</div>
)
}
// ── Users + role + write-mute (§6.1 / §6.2) ────────────────────────────────
function UsersTab() {
const [users, setUsers] = useState(null)
const [busy, setBusy] = useState({})
const [error, setError] = useState(null)
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 }))
}
}
if (users == null) return <p className="muted">Loading users</p>
return (
<div className="admin-tab">
<header className="admin-tab-header">
<h2>Users</h2>
<p className="muted">
Role changes write to <code>permission_events</code>. The §6.2
write-mute applies to contributors only promote to admin to
remove a user's ability to write without silencing them.
</p>
</header>
{error && <p className="settings-note warning">{error}</p>}
<table className="admin-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Write-muted</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td>
<div className="user-cell">
<span className="user-handle">@{u.gitea_login}</span>
<span className="muted">{u.display_name}</span>
</div>
</td>
<td>
<select
value={u.role}
onChange={e => changeRole(u.id, e.target.value)}
disabled={!!busy[u.id]}
>
<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 => toggleMute(u.id, e.target.checked)}
disabled={!!busy[u.id]}
/>
{u.muted ? 'Muted' : 'Active'}
</label>
) : (
<span className="muted">N/A</span>
)}
</td>
<td className="muted">{u.last_seen_at}</td>
</tr>
))}
</tbody>
</table>
</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)
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={`/rfc/${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={`/rfc/${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>
)
}
// ── 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)
}