999c4b65ef
Implements the M3-frontend slice of the §22 multi-project track, per docs/superpowers/specs/2026-06-03-m3-frontend-design.md (design merged in #10). Completes the runtime-config cut 0.33.0 (M3-backend Plan A) began. Frontend: - DeploymentProvider boots GET /api/deployment → {name, tagline, defaultProjectId, projects}; brandTitle() neutral 'RFC' pre-fetch fallback. - /p/:projectId/* routing with generic /e/<slug> segment. ProjectLayout fetches /api/projects/:id, applies per-project theme (reset on switch), provides ProjectContext, guards the corpus (served only for the default; others get NotServedPlaceholder — decouples this slice from Plan B). - Directory at / (2+ projects) with N=1 redirect into the single project; ProjectSwitcher in deployment chrome; entry-noun by project type. - VITE_APP_NAME hard cut: removed from vite.config + index.html; the 6 brand reads now use deployment.name via context; static <title>RFC</title> + JS document.title. Internal /rfc·/proposals links → /p/<project>/e|proposals via lib/entryPaths. Backend: - GET /api/deployment returns default_project_id (the guard contract). - Server-side 308s: /rfc/<slug>, /rfc/<slug>/pr/<n>, /proposals/<n> → /p/<default>/… . nginx (testing + prod) routes /rfc/ and /proposals/ to the backend. Tests: 3 new backend redirect/deployment tests (438 pass); Vitest unit for DeploymentProvider, ProjectLayout (theme/guard/404), Directory (11 pass); clean build with no VITE_APP_NAME. Playwright e2e deferred until Tier-1 seeds a registry (see CHANGELOG 0.35.0 step 5). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1018 lines
33 KiB
React
1018 lines
33 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,
|
||
setUserPermission,
|
||
listAuditLog,
|
||
listPermissionEvents,
|
||
listGraduationQueue,
|
||
listAllowlist,
|
||
addAllowlistEmail,
|
||
removeAllowlistEmail,
|
||
createUserInvite,
|
||
listRetiredRFCs,
|
||
unretireRFC,
|
||
} from '../api.js'
|
||
import { EVENTS, track } from '../lib/analytics.js'
|
||
import { entryPath, useProjectId } from '../lib/entryPaths'
|
||
|
||
// v0.17.0 — roadmap item #16. The max length the backend enforces
|
||
// (Pydantic body bound + `invites.CUSTOM_MESSAGE_MAX_LENGTH`); kept
|
||
// here so the modal's "remaining chars" counter stays in lockstep
|
||
// with the server-side bound.
|
||
const CUSTOM_MESSAGE_MAX_LENGTH = 500
|
||
|
||
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 }) {
|
||
// §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 => (
|
||
<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="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>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Users + role + write-mute + permission grant/revoke (§6.1 / §6.2) ──────
|
||
//
|
||
// v0.9.0 (roadmap item #7) lands the user-management surface. The table
|
||
// shows every user with their permission_state, sign-up reason (when
|
||
// pending), role, write-mute, and Grant / Revoke controls. State filter
|
||
// chips above the table narrow to one bucket — the "Pending" chip is the
|
||
// admin's daily inbox shape.
|
||
|
||
const STATE_CHIPS = [
|
||
{ value: 'all', label: 'All' },
|
||
{ value: 'pending', label: 'Pending' },
|
||
{ value: 'granted', label: 'Granted' },
|
||
{ value: 'revoked', label: 'Revoked' },
|
||
]
|
||
|
||
function UsersTab() {
|
||
const [users, setUsers] = useState(null)
|
||
const [busy, setBusy] = useState({})
|
||
const [error, setError] = useState(null)
|
||
const [stateFilter, setStateFilter] = useState('all')
|
||
// v0.17.0 — roadmap item #16. The "Create user + invite" modal's
|
||
// open/closed state. The modal is local to UsersTab (it only opens
|
||
// from the header button) and refreshes the listing on success.
|
||
const [inviteModalOpen, setInviteModalOpen] = useState(false)
|
||
|
||
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 }))
|
||
}
|
||
}
|
||
|
||
async function flipPermission(userId, state) {
|
||
setBusy(b => ({ ...b, [userId]: true }))
|
||
setError(null)
|
||
try {
|
||
await setUserPermission(userId, state)
|
||
// v0.15.0 — analytics: fire on a successful §6.1 grant/revoke.
|
||
// action collapses the {pending → granted, revoked → granted}
|
||
// edges onto `grant`, and `granted → revoked` onto `revoke`,
|
||
// matching the roadmap's two-arm taxonomy.
|
||
const action = state === 'granted' ? 'grant' : 'revoke'
|
||
track(EVENTS.ADMIN_PERMISSION_DECISION, { action, target_user_id: String(userId) })
|
||
// Refresh the full row so permission_decided_{at,by_*} update too.
|
||
await refresh()
|
||
} catch (e) {
|
||
setError(e.message)
|
||
} finally {
|
||
setBusy(b => ({ ...b, [userId]: false }))
|
||
}
|
||
}
|
||
|
||
const counts = useMemo(() => {
|
||
const c = { all: 0, pending: 0, granted: 0, revoked: 0 }
|
||
if (users) {
|
||
c.all = users.length
|
||
for (const u of users) {
|
||
const s = u.permission_state || 'granted'
|
||
if (s in c) c[s] += 1
|
||
}
|
||
}
|
||
return c
|
||
}, [users])
|
||
|
||
if (users == null) return <p className="muted">Loading users…</p>
|
||
|
||
const filtered = stateFilter === 'all'
|
||
? users
|
||
: users.filter(u => (u.permission_state || 'granted') === stateFilter)
|
||
|
||
return (
|
||
<div className="admin-tab">
|
||
<header className="admin-tab-header">
|
||
<div className="admin-tab-heading">
|
||
<h2>Users</h2>
|
||
{/* v0.17.0 — roadmap item #16. The "Create user + invite"
|
||
affordance opens a modal that provisions a fresh users row
|
||
with the chosen role and sends an invite email with a
|
||
single-use claim link. */}
|
||
<div className="admin-tab-actions">
|
||
<button
|
||
type="button"
|
||
className="btn-primary"
|
||
onClick={() => setInviteModalOpen(true)}
|
||
>Create user + invite</button>
|
||
</div>
|
||
</div>
|
||
<p className="muted">
|
||
The pending bucket is the beta-access review queue (§6.1 /
|
||
v0.8.0). Grant or revoke writes to <code>permission_events</code>
|
||
and stamps <code>permission_decided_by</code> +{' '}
|
||
<code>permission_decided_at</code>. Role and write-mute controls
|
||
retain their v0.7.0 semantics — promote to admin to remove a
|
||
user's ability to write without silencing them.
|
||
</p>
|
||
</header>
|
||
{error && <p className="settings-note warning">{error}</p>}
|
||
{inviteModalOpen && (
|
||
<CreateUserInviteModal
|
||
onClose={() => setInviteModalOpen(false)}
|
||
onSuccess={async () => {
|
||
setInviteModalOpen(false)
|
||
await refresh()
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
<div className="admin-filter-chips">
|
||
{STATE_CHIPS.map(chip => (
|
||
<button
|
||
key={chip.value}
|
||
type="button"
|
||
className={`admin-chip${stateFilter === chip.value ? ' active' : ''}`}
|
||
onClick={() => setStateFilter(chip.value)}
|
||
>
|
||
{chip.label} <span className="admin-chip-count">{counts[chip.value] ?? 0}</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{filtered.length === 0 ? (
|
||
<p className="muted">No users in this bucket.</p>
|
||
) : (
|
||
<table className="admin-table admin-users-table">
|
||
<thead>
|
||
<tr>
|
||
<th>User</th>
|
||
<th>State</th>
|
||
<th>Role</th>
|
||
<th>Write-muted</th>
|
||
<th>Signed up</th>
|
||
<th>Last seen</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filtered.map(u => (
|
||
<UserRow
|
||
key={u.id}
|
||
user={u}
|
||
busy={!!busy[u.id]}
|
||
onChangeRole={role => changeRole(u.id, role)}
|
||
onToggleMute={muted => toggleMute(u.id, muted)}
|
||
onFlipPermission={state => flipPermission(u.id, state)}
|
||
/>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function UserRow({ user: u, busy, onChangeRole, onToggleMute, onFlipPermission }) {
|
||
const state = u.permission_state || 'granted'
|
||
const fullName = [u.first_name, u.last_name].filter(Boolean).join(' ').trim()
|
||
const handle = u.gitea_login ? `@${u.gitea_login}` : (u.email || u.display_name)
|
||
// v0.17.0 — roadmap item #16. The user's row may also be the
|
||
// "(pending invite)" shape: admin-created via POST /api/admin/users,
|
||
// not yet claimed via /api/invites/claim. The backend's user-listing
|
||
// surfaces this via `pending_invite` (object with invite_id +
|
||
// expires_at) or null. The badge sits inline next to the handle so
|
||
// the admin sees at a glance which rows are real users vs. unclaimed
|
||
// invites.
|
||
const pendingInvite = u.pending_invite
|
||
// When there's no gitea_login the handle already IS the email, so the
|
||
// subline would otherwise repeat it. Only append the email when it adds
|
||
// something the handle doesn't already show.
|
||
const showEmail = u.email && u.email !== handle
|
||
return (
|
||
<>
|
||
<tr>
|
||
<td>
|
||
<div className="user-cell">
|
||
<div className="user-cell-handle">
|
||
<span className="user-handle">{handle}</span>
|
||
{pendingInvite && (
|
||
<span
|
||
className="invite-badge"
|
||
title={`Admin-created invite; expires ${pendingInvite.expires_at}`}
|
||
>pending invite</span>
|
||
)}
|
||
</div>
|
||
<span className="muted">
|
||
{fullName || u.display_name}
|
||
{showEmail ? ` · ${u.email}` : ''}
|
||
</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<PermissionCell user={u} busy={busy} onFlipPermission={onFlipPermission} />
|
||
</td>
|
||
<td>
|
||
<select
|
||
value={u.role}
|
||
onChange={e => onChangeRole(e.target.value)}
|
||
disabled={busy}
|
||
>
|
||
<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 => onToggleMute(e.target.checked)}
|
||
disabled={busy}
|
||
/>
|
||
{u.muted ? 'Muted' : 'Active'}
|
||
</label>
|
||
) : (
|
||
<span className="muted">N/A</span>
|
||
)}
|
||
</td>
|
||
<TimeCell value={u.created_at} />
|
||
{/* An unclaimed admin invite has provably never authenticated, so
|
||
last_seen_at is just the row-creation default (it equals
|
||
created_at). Render the truth — "Never" — rather than a
|
||
timestamp that reads like a real visit. */}
|
||
<TimeCell
|
||
value={pendingInvite ? null : u.last_seen_at}
|
||
emptyLabel={pendingInvite ? 'Never' : '—'}
|
||
/>
|
||
</tr>
|
||
{state === 'pending' && u.beta_request_reason ? (
|
||
<tr className="user-row-reason">
|
||
<td colSpan={6}>
|
||
<div className="user-reason-block">
|
||
<strong>Why they want access:</strong>
|
||
<p>{u.beta_request_reason}</p>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
) : null}
|
||
</>
|
||
)
|
||
}
|
||
|
||
// Render a "YYYY-MM-DD HH:MM:SS" timestamp as an intentional date-over-time
|
||
// stack (date prominent, time quiet below) rather than letting a narrow
|
||
// column wrap the value mid-string. Falls back to an em-dash when absent.
|
||
function TimeCell({ value, emptyLabel = '—' }) {
|
||
if (!value) return <td className="muted">{emptyLabel}</td>
|
||
const [date, ...rest] = String(value).split(' ')
|
||
const time = rest.join(' ')
|
||
return (
|
||
<td className="user-when">
|
||
<span className="user-when-date">{date}</span>
|
||
{time && <span className="user-when-time muted">{time}</span>}
|
||
</td>
|
||
)
|
||
}
|
||
|
||
function PermissionCell({ user: u, busy, onFlipPermission }) {
|
||
const state = u.permission_state || 'granted'
|
||
const decidedSuffix = u.permission_decided_at
|
||
? ` · by ${u.permission_decided_by_login ? '@' + u.permission_decided_by_login : '—'} at ${u.permission_decided_at}`
|
||
: ''
|
||
return (
|
||
<div className="permission-cell">
|
||
<span className={`permission-badge permission-badge-${state}`}>{state}</span>
|
||
<div className="permission-actions">
|
||
{state !== 'granted' && (
|
||
<button
|
||
type="button"
|
||
className="btn-link-quiet"
|
||
disabled={busy}
|
||
onClick={() => onFlipPermission('granted')}
|
||
>Grant</button>
|
||
)}
|
||
{state === 'granted' && (
|
||
<button
|
||
type="button"
|
||
className="btn-link-quiet"
|
||
disabled={busy}
|
||
onClick={() => {
|
||
if (confirm(`Revoke access for ${u.display_name || u.email}?`)) {
|
||
onFlipPermission('revoked')
|
||
}
|
||
}}
|
||
>Revoke</button>
|
||
)}
|
||
</div>
|
||
{decidedSuffix && (
|
||
<div className="permission-decided muted">{decidedSuffix.replace(/^ · /, '')}</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Create user + invite modal (v0.17.0 / roadmap item #16) ────────────────
|
||
//
|
||
// The "Create user + invite" affordance on the Users tab opens this
|
||
// modal. Admin types email, first name, last name, role, and (optionally)
|
||
// a custom message to embed in the invite email. On submit, calls
|
||
// `POST /api/admin/users` which provisions the row + sends the email.
|
||
// The 409 path (duplicate email) and 422 path (self-invite, owner-
|
||
// grant-by-non-owner, malformed input) surface the server's message
|
||
// inline; the success path closes the modal and refreshes the listing.
|
||
//
|
||
// The modal lives in this file rather than a separate component
|
||
// because it has one caller (UsersTab), reuses the existing modal
|
||
// stylesheet from /admin's chrome, and shares the
|
||
// CUSTOM_MESSAGE_MAX_LENGTH constant defined at the top of the file.
|
||
|
||
function CreateUserInviteModal({ onClose, onSuccess }) {
|
||
const [email, setEmail] = useState('')
|
||
const [firstName, setFirstName] = useState('')
|
||
const [lastName, setLastName] = useState('')
|
||
const [role, setRole] = useState('contributor')
|
||
const [customMessage, setCustomMessage] = useState('')
|
||
const [busy, setBusy] = useState(false)
|
||
const [error, setError] = useState(null)
|
||
const [success, setSuccess] = useState(null)
|
||
|
||
const remaining = CUSTOM_MESSAGE_MAX_LENGTH - customMessage.length
|
||
|
||
async function handleSubmit(event) {
|
||
event.preventDefault()
|
||
const trimmedEmail = email.trim()
|
||
if (!trimmedEmail) {
|
||
setError('Email is required')
|
||
return
|
||
}
|
||
setBusy(true)
|
||
setError(null)
|
||
setSuccess(null)
|
||
try {
|
||
const result = await createUserInvite({
|
||
email: trimmedEmail,
|
||
first_name: firstName.trim(),
|
||
last_name: lastName.trim(),
|
||
role,
|
||
custom_message: customMessage,
|
||
})
|
||
// v0.17.0 + #21 Part C — Amplitude wiring. target_user_id is
|
||
// the OHM user id the invite-create gesture provisioned;
|
||
// initial_role is what the invitee inherits on claim.
|
||
// custom_message_chars is a coarse signal of admin effort
|
||
// (0 = template-only, 1+ = personalized). No PII.
|
||
track(EVENTS.USER_INVITED, {
|
||
target_user_id: result.invited_user_id != null
|
||
? String(result.invited_user_id) : null,
|
||
initial_role: result.role,
|
||
custom_message_chars: (customMessage || '').length,
|
||
})
|
||
setSuccess(`Invite sent to ${result.email} (${result.role}).`)
|
||
// Brief delay so the admin sees the success state, then close
|
||
// and let the parent refresh the listing.
|
||
setTimeout(() => { onSuccess?.() }, 600)
|
||
} catch (e) {
|
||
setError(e.message || 'Unable to send invite')
|
||
} finally {
|
||
setBusy(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="modal-backdrop" onClick={onClose}>
|
||
<div className="modal-panel" onClick={e => e.stopPropagation()}>
|
||
<header className="modal-header">
|
||
<h3>Create user + invite</h3>
|
||
<button
|
||
type="button"
|
||
className="btn-link-quiet"
|
||
onClick={onClose}
|
||
disabled={busy}
|
||
aria-label="Close"
|
||
>×</button>
|
||
</header>
|
||
<p className="muted">
|
||
Provisions a fresh user row with the chosen role and sends an
|
||
invite email carrying a single-use claim link. The link
|
||
expires in 7 days. The invitee clicks through to claim
|
||
their account — no OTC roundtrip is required on first sign-in.
|
||
</p>
|
||
<form onSubmit={handleSubmit} className="create-user-invite-form">
|
||
<label>
|
||
<span>Email</span>
|
||
<input
|
||
type="email"
|
||
value={email}
|
||
onChange={e => setEmail(e.target.value)}
|
||
required
|
||
disabled={busy}
|
||
autoFocus
|
||
maxLength={320}
|
||
/>
|
||
</label>
|
||
<div className="form-row">
|
||
<label>
|
||
<span>First name</span>
|
||
<input
|
||
type="text"
|
||
value={firstName}
|
||
onChange={e => setFirstName(e.target.value)}
|
||
disabled={busy}
|
||
maxLength={120}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>Last name</span>
|
||
<input
|
||
type="text"
|
||
value={lastName}
|
||
onChange={e => setLastName(e.target.value)}
|
||
disabled={busy}
|
||
maxLength={120}
|
||
/>
|
||
</label>
|
||
</div>
|
||
<label>
|
||
<span>Role</span>
|
||
<select
|
||
value={role}
|
||
onChange={e => setRole(e.target.value)}
|
||
disabled={busy}
|
||
>
|
||
<option value="contributor">Contributor</option>
|
||
<option value="admin">Admin</option>
|
||
<option value="owner">Owner (owner-only)</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>
|
||
Custom message (optional){' '}
|
||
<span className={`muted${remaining < 0 ? ' warning' : ''}`}>
|
||
{remaining} chars left
|
||
</span>
|
||
</span>
|
||
<textarea
|
||
value={customMessage}
|
||
onChange={e => setCustomMessage(e.target.value)}
|
||
disabled={busy}
|
||
rows={4}
|
||
maxLength={CUSTOM_MESSAGE_MAX_LENGTH}
|
||
placeholder="Optional — embedded in the invite email."
|
||
/>
|
||
</label>
|
||
{error && <p className="settings-note warning">{error}</p>}
|
||
{success && <p className="settings-note success">{success}</p>}
|
||
<div className="modal-actions">
|
||
<button type="button" onClick={onClose} disabled={busy}>Cancel</button>
|
||
<button
|
||
type="submit"
|
||
className="btn-primary"
|
||
disabled={busy || !email.trim() || remaining < 0}
|
||
>
|
||
{busy ? 'Sending…' : 'Send invite'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── 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 <p className="muted">Loading allowlist…</p>
|
||
|
||
return (
|
||
<div className="admin-tab">
|
||
<header className="admin-tab-header">
|
||
<h2>Allowlist</h2>
|
||
<p className="muted">
|
||
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.
|
||
</p>
|
||
<p className="muted">
|
||
Status:{' '}
|
||
<strong>{data?.active ? 'Private beta — gate active' : 'Open — anyone can sign in'}</strong>
|
||
</p>
|
||
</header>
|
||
{error && <p className="settings-note warning">{error}</p>}
|
||
|
||
<form className="allowlist-add" onSubmit={handleAdd}>
|
||
<input
|
||
type="email"
|
||
placeholder="email@example.com"
|
||
value={draftEmail}
|
||
onChange={e => setDraftEmail(e.target.value)}
|
||
required
|
||
disabled={busy}
|
||
/>
|
||
<input
|
||
type="text"
|
||
placeholder="Note (optional)"
|
||
value={draftNote}
|
||
onChange={e => setDraftNote(e.target.value)}
|
||
maxLength={200}
|
||
disabled={busy}
|
||
/>
|
||
<button type="submit" className="btn-primary" disabled={busy || !draftEmail.trim()}>
|
||
Add to allowlist
|
||
</button>
|
||
</form>
|
||
|
||
{data?.items?.length > 0 ? (
|
||
<table className="admin-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Email</th>
|
||
<th>Note</th>
|
||
<th>Added by</th>
|
||
<th>Added at</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{data.items.map(r => (
|
||
<tr key={r.email}>
|
||
<td><code>{r.email}</code></td>
|
||
<td>{r.note || <span className="muted">—</span>}</td>
|
||
<td>
|
||
{r.added_by_login
|
||
? <span>@{r.added_by_login}</span>
|
||
: <span className="muted">—</span>}
|
||
</td>
|
||
<td className="muted">{r.created_at}</td>
|
||
<td>
|
||
<button
|
||
type="button"
|
||
className="btn-link-quiet"
|
||
onClick={() => handleRemove(r.email)}
|
||
disabled={busy}
|
||
>Remove</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
) : (
|
||
<p className="muted">
|
||
No allow-listed emails yet. Add the first one to put the deployment
|
||
into private-beta mode.
|
||
</p>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// ── Graduation-readiness queue (§13.2) ─────────────────────────────────────
|
||
|
||
function GraduationTab() {
|
||
const [data, setData] = useState(null)
|
||
const [error, setError] = useState(null)
|
||
const pid = useProjectId()
|
||
|
||
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={entryPath(pid, 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={entryPath(pid, 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>
|
||
)
|
||
}
|
||
|
||
// ── 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() {
|
||
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)
|
||
}
|