7e595b6e5e
An admin-created invite row showed a Last-seen timestamp identical to
Signed-up, implying the invitee had visited. users.last_seen_at is
NOT NULL DEFAULT (datetime('now')) and the invite INSERT sets neither
timestamp, so both default to row-creation time; last_seen_at only
advances on real authentication. An unclaimed invite has provably never
authenticated (the unclaimed state drives the PENDING INVITE badge), so
the Users tab now renders "Never" for the Last-seen cell of a pending
row. Signed-up (invite-created date) unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
937 lines
31 KiB
React
937 lines
31 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,
|
||
} from '../api.js'
|
||
import { EVENTS, track } from '../lib/analytics.js'
|
||
|
||
// 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 }) {
|
||
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 />} />
|
||
<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)
|
||
|
||
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)
|
||
}
|