060fa408a2
§14.1 richer landing, §14.2 /philosophy route (disk-backed), §14.3 persistent About link. /settings/notifications surfaces Slice 6's preferences/quiet-hours/mute/watches endpoints. /admin home base consolidates role management, the §6.2 write-mute, the audit-log viewer, the permission-events log, and the §13.2 graduation queue. Backend: backend/app/philosophy.py, backend/app/api_admin.py (seven admin endpoints + user-search), GET /api/users/me/notification-mutes. Frontend: Landing.jsx (deck), Philosophy.jsx, NotificationSettings.jsx, Admin.jsx, App.jsx routing for the chrome surfaces. Tests: backend/tests/test_chrome_vertical.py — 13 cases. Full suite 75/75 green. Spec corrections: §14.2 (PHILOSOPHY.md source is a deployment-time decision), §17 (admin block extended to name the seven new endpoints + user-search and notification-mutes read). §19.1 rewritten for Slice 8 hardening; §19.2 grew four candidates (owner succession, mute-from-actor, the "Following since <date>" disclosure, audit-log row prose). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
409 lines
13 KiB
React
409 lines
13 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,
|
|
} 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 (
|
|
<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="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>
|
|
)
|
|
}
|
|
|
|
// ── 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)
|
|
}
|