// /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 (
} />
} />
} />
} />
} />
} />
)
}
// ── 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
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.
{/* 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. */}
)
}
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
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.