§13: make graduation's RFC number optional; add retire (soft delete)
Optional number (§13.2/§13.3): GraduateBody.rfc_id is now optional.
A blank/absent id graduates to `active` with `id: null`, leaving the
slug as the canonical identifier (§2.3). /graduate/check treats a blank
id as valid (ok:true); /graduate only validates the RFC-NNNN regex +
collision check when a number is supplied. The Graduate dialog allows an
empty number and renders number-less entries by slug (no RFC-undefined).
Retire (§3, §3.1, §13.7): new `retired` soft-delete state. An RFC's own
owners (frontmatter) and site `owner`-role holders — not app admins —
retire via POST /api/rfcs/<slug>/retire, an auto-merged meta-repo flip
PR (graduation machinery reused). Retired entries leave every browsing
surface: catalog, get_rfc (404 except site owner), discussion/branch
reads. Un-retire is site-owner-only (POST .../unretire), discoverable
via the owner-gated GET /api/admin/retired-rfcs ("Retired" admin tab).
Migration 025 widens the cached_rfcs.state CHECK to include 'retired'
(table rebuild, all columns preserved).
Tests: graduation no-number cases (active+null id by slug; check accepts
blank) added to test_graduation_vertical.py; new test_retire_vertical.py
covers perms (owner/site-owner allowed, admin/contributor 403),
catalog/read exclusion, and a graduate→retire→unretire round-trip. Full
backend suite 386 passing; frontend builds clean. SPEC §3/§3.1/§13
updated; CHANGELOG + VERSION → 0.33.0.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -24,6 +24,8 @@ import {
|
||||
addAllowlistEmail,
|
||||
removeAllowlistEmail,
|
||||
createUserInvite,
|
||||
listRetiredRFCs,
|
||||
unretireRFC,
|
||||
} from '../api.js'
|
||||
import { EVENTS, track } from '../lib/analytics.js'
|
||||
|
||||
@@ -42,12 +44,18 @@ const TABS = [
|
||||
]
|
||||
|
||||
export default function Admin({ viewer }) {
|
||||
// §13.7: the "Retired" surface (un-retire) is site-owner-only. The
|
||||
// backend gates /api/admin/retired-rfcs and /unretire on the owner role
|
||||
// regardless; we only show the link to owners so admins aren't offered
|
||||
// a tab that would 403.
|
||||
const isSiteOwner = viewer.role === 'owner'
|
||||
const tabs = isSiteOwner ? [...TABS, { path: 'retired', label: 'Retired' }] : TABS
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<nav className="admin-rail">
|
||||
<h2>Admin</h2>
|
||||
<ul>
|
||||
{TABS.map(t => (
|
||||
{tabs.map(t => (
|
||||
<li key={t.path}>
|
||||
<NavLink
|
||||
to={t.path}
|
||||
@@ -69,6 +77,7 @@ export default function Admin({ viewer }) {
|
||||
<Route path="users" element={<UsersTab />} />
|
||||
<Route path="allowlist" element={<AllowlistTab />} />
|
||||
<Route path="graduation" element={<GraduationTab />} />
|
||||
{isSiteOwner && <Route path="retired" element={<RetiredTab />} />}
|
||||
<Route path="audit" element={<AuditTab />} />
|
||||
<Route path="permissions" element={<PermissionsTab />} />
|
||||
</Routes>
|
||||
@@ -765,6 +774,76 @@ function GraduationTab() {
|
||||
)
|
||||
}
|
||||
|
||||
// ── Retired (soft-deleted) entries — site-owner-only un-retire (§13.7) ─────
|
||||
|
||||
function RetiredTab() {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [busy, setBusy] = useState({})
|
||||
|
||||
const load = () => {
|
||||
listRetiredRFCs()
|
||||
.then(setData)
|
||||
.catch(e => setError(e.message))
|
||||
}
|
||||
useEffect(load, [])
|
||||
|
||||
const onUnretire = async (slug) => {
|
||||
setBusy(b => ({ ...b, [slug]: true }))
|
||||
setError(null)
|
||||
try {
|
||||
await unretireRFC(slug)
|
||||
load()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} finally {
|
||||
setBusy(b => ({ ...b, [slug]: false }))
|
||||
}
|
||||
}
|
||||
|
||||
if (error) return <p className="settings-note warning">{error}</p>
|
||||
if (data == null) return <p className="muted">Loading retired entries…</p>
|
||||
|
||||
return (
|
||||
<div className="admin-tab">
|
||||
<header className="admin-tab-header">
|
||||
<h2>Retired</h2>
|
||||
<p className="muted">
|
||||
Soft-deleted RFCs (§13.7). They are hidden from the catalog and
|
||||
every view; the entry stays in the meta repo. Un-retiring restores
|
||||
an entry to the state it held before. Site owners only.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<h3 className="admin-section-h">Retired ({data.items.length})</h3>
|
||||
{data.items.length === 0 && (
|
||||
<p className="muted">No retired entries.</p>
|
||||
)}
|
||||
<ul className="grad-queue">
|
||||
{data.items.map(item => (
|
||||
<li key={item.slug}>
|
||||
<span className="grad-queue-link">
|
||||
<strong>{item.title}</strong>
|
||||
<span className="muted">
|
||||
{' — '}{item.id || item.slug}
|
||||
{`; restores to ${item.restores_to}`}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-secondary"
|
||||
disabled={!!busy[item.slug]}
|
||||
onClick={() => onUnretire(item.slug)}
|
||||
>
|
||||
{busy[item.slug] ? 'Un-retiring…' : 'Un-retire'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Audit log (`actions`) — filter chips + paging ──────────────────────────
|
||||
|
||||
function AuditTab() {
|
||||
|
||||
Reference in New Issue
Block a user