// /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, } from '../api.js' const TABS = [ { path: 'users', label: 'Users' }, { 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}
) } // ── 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) }