// /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 (
Admin
{TABS.map(t => (
`admin-rail-link ${isActive ? 'active' : ''}`}
>
{t.label}
))}
Signed in as {viewer.display_name} ({viewer.role}).
You can return to the catalog at any time.
} />
} />
} />
} />
} />
)
}
// ── 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 (
)
}
// ── 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 (
Ready ({data.ready.length})
{data.ready.length === 0 && (
No super-drafts ready right now.
)}
{data.ready.map(item => (
{item.title}
— owners: {item.owners.join(', ')}
))}
Blocked ({data.blocked.length})
{data.blocked.length === 0 && (
No blocked super-drafts.
)}
{data.blocked.map(item => (
{item.title}
{' — '}
{!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'}`}
))}
)
}
// ── 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, actionKind: e.target.value }))}
>
All action kinds
{kinds.map(k => {k} )}
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 && (
When
Action
Actor
On behalf of
RFC
PR / branch
{data.items.map(row => (
{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 && (
load(data.items[data.items.length - 1].id)}
>
Load older →
)}
)
}
// ── 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 (
{data.items.length === 0 && (
No permission events yet.
)}
{data.items.length > 0 && (
When
Event
Actor
Subject
Details
{data.items.map(r => (
{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)
}