// /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 (
} /> } /> } /> } /> } /> } />
) } // ── 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

Loading users…

return (

Users

Role changes write to permission_events. The §6.2 write-mute applies to contributors only — promote to admin to remove a user's ability to write without silencing them.

{error &&

{error}

} {users.map(u => ( ))}
User Role Write-muted Last seen
@{u.gitea_login} {u.display_name}
{u.role === 'contributor' ? ( ) : ( N/A )} {u.last_seen_at}
) } // ── 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

Loading allowlist…

return (

Allowlist

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.

Status:{' '} {data?.active ? 'Private beta — gate active' : 'Open — anyone can sign in'}

{error &&

{error}

}
setDraftEmail(e.target.value)} required disabled={busy} /> setDraftNote(e.target.value)} maxLength={200} disabled={busy} />
{data?.items?.length > 0 ? ( {data.items.map(r => ( ))}
Email Note Added by Added at
{r.email} {r.note || } {r.added_by_login ? @{r.added_by_login} : } {r.created_at}
) : (

No allow-listed emails yet. Add the first one to put the deployment into private-beta mode.

)}
) } // ── 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

{error}

if (data == null) return

Loading queue…

return (

Graduation queue

Super-drafts with owners claimed and zero blocking body-edit PRs. Open one to run the §13.3 graduation sequence.

Ready ({data.ready.length})

{data.ready.length === 0 && (

No super-drafts ready right now.

)}

Blocked ({data.blocked.length})

{data.blocked.length === 0 && (

No blocked super-drafts.

)}
) } // ── 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 (

Audit log

Every bot-mediated write lands here. The most recent rows show first; filter to narrow to one kind, one actor, or one RFC.

setFilters(f => ({ ...f, rfcSlug: e.target.value }))} /> setFilters(f => ({ ...f, actorUserId: e.target.value }))} />
{error &&

{error}

} {data == null &&

Loading…

} {data?.items?.length === 0 && (

No rows match this filter.

)} {data?.items?.length > 0 && ( {data.items.map(row => ( ))}
When Action Actor On behalf of RFC PR / branch
{row.created_at} {row.action_kind} {row.actor_display || row.actor_login || '—'} {row.on_behalf_of} {row.rfc_slug || '—'} {row.pr_number != null && #{row.pr_number} } {row.branch_name && {row.branch_name}}
)} {data?.has_more && ( )}
) } // ── 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

{error}

if (data == null) return

Loading…

return (

Permission events

Every role change and write-mute toggle. The companion to the audit log, scoped to authorization changes.

{data.items.length === 0 && (

No permission events yet.

)} {data.items.length > 0 && ( {data.items.map(r => ( ))}
When Event Actor Subject Details
{r.created_at} {r.event_kind} {r.actor_display || r.actor_login || '—'} {r.subject_display || r.subject_login || '—'} {r.details ? formatDetails(r.details) : ''}
)}
) } 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) }