// /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 (
{/* 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. */}
The pending bucket is the beta-access review queue (§6.1 /
v0.8.0). Grant or revoke writes to permission_events
and stamps permission_decided_by +{' '}
permission_decided_at. 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.
)
}
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 (
<>
{/* 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. */}
{state === 'pending' && u.beta_request_reason ? (
Why they want access:
{u.beta_request_reason}
) : 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
)
}
// ── 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 (
e.stopPropagation()}>
Create user + invite
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.
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.
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.
Retired ({data.items.length})
{data.items.length === 0 && (
No retired entries.
)}
{data.items.map(item => (
{item.title}
{' — '}{item.id || item.slug}
{`; restores to ${item.restores_to}`}