Release 0.9.0: admin user-management page + new-request notifications

This commit is contained in:
Ben Stull
2026-05-28 03:39:25 -07:00
parent de28272914
commit 7872b921ed
14 changed files with 1204 additions and 119 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rfc-app-frontend",
"version": "0.10.0",
"version": "0.9.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.10.0",
"version": "0.9.0",
"dependencies": {
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-markdown": "^6.5.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.14.0",
"version": "0.9.0",
"type": "module",
"scripts": {
"dev": "vite",
+48
View File
@@ -1889,6 +1889,54 @@
display: inline-flex; align-items: center; gap: 6px;
font-size: 13px; cursor: pointer;
}
/* v0.9.0 — admin user-management surface (roadmap item #7). */
.admin-filter-chips {
display: flex; gap: 6px; margin-bottom: 16px; flex-wrap: wrap;
}
.admin-chip {
display: inline-flex; align-items: center; gap: 6px;
background: #fff; border: 1px solid #d1d5db; border-radius: 999px;
padding: 4px 12px; font-size: 12px; color: #374151; cursor: pointer;
}
.admin-chip:hover { background: #f9fafb; }
.admin-chip.active {
background: #111; color: #fff; border-color: #111;
}
.admin-chip-count {
font-size: 11px; opacity: 0.7;
}
.admin-users-table td { vertical-align: top; padding-top: 10px; padding-bottom: 10px; }
.permission-cell { display: flex; flex-direction: column; gap: 4px; }
.permission-actions { display: flex; gap: 6px; }
.permission-badge {
display: inline-block;
font-size: 11px; font-weight: 600;
padding: 2px 8px; border-radius: 999px;
text-transform: uppercase; letter-spacing: 0.04em;
width: max-content;
}
.permission-badge-pending {
background: #fef3c7; color: #92400e;
}
.permission-badge-granted {
background: #dcfce7; color: #166534;
}
.permission-badge-revoked {
background: #fee2e2; color: #991b1b;
}
.permission-decided { font-size: 11px; }
.user-row-reason td {
background: #fffbeb; border-top: none !important;
padding: 0 16px 12px !important;
}
.user-reason-block {
border-left: 3px solid #f59e0b;
padding: 8px 12px; font-size: 13px;
background: #fffbeb;
}
.user-reason-block strong { display: block; margin-bottom: 4px; color: #92400e; }
.user-reason-block p { margin: 0; white-space: pre-wrap; color: #374151; }
.grad-queue { list-style: none; padding: 0; margin: 8px 0 24px; }
.grad-queue li { padding: 8px 0; border-bottom: 1px solid #f3f4f6; }
.grad-queue-link { color: #111; text-decoration: none; font-size: 14px; }
+13
View File
@@ -661,6 +661,19 @@ export async function setUserMute(userId, muted) {
}))
}
// v0.9.0 — roadmap item #7. Flip a user's permission_state between
// 'pending', 'granted', and 'revoked'. The Users tab on the admin
// page wires Grant / Revoke buttons against this endpoint; the
// returned `changed` flag is false when the requested state already
// matched the row.
export async function setUserPermission(userId, state) {
return jsonOrThrow(await fetch(`/api/admin/users/${userId}/permission`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state }),
}))
}
export async function listAuditLog({ actionKind, actorUserId, rfcSlug, beforeId, limit } = {}) {
const params = new URLSearchParams()
if (actionKind) params.set('action_kind', actionKind)
+192 -52
View File
@@ -16,6 +16,7 @@ import {
listAdminUsers,
setUserRole,
setUserMute,
setUserPermission,
listAuditLog,
listPermissionEvents,
listGraduationQueue,
@@ -68,12 +69,26 @@ export default function Admin({ viewer }) {
)
}
// Users + role + write-mute (§6.1 / §6.2)
// 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')
async function refresh() {
setError(null)
@@ -113,68 +128,193 @@ function UsersTab() {
}
}
async function flipPermission(userId, state) {
setBusy(b => ({ ...b, [userId]: true }))
setError(null)
try {
await setUserPermission(userId, state)
// 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">
<h2>Users</h2>
<p className="muted">
Role changes write to <code>permission_events</code>. The §6.2
write-mute applies to contributors only promote to admin to
remove a user's ability to write without silencing them.
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>}
<table className="admin-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Write-muted</th>
<th>Last seen</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id}>
<td>
<div className="user-cell">
<span className="user-handle">@{u.gitea_login}</span>
<span className="muted">{u.display_name}</span>
</div>
</td>
<td>
<select
value={u.role}
onChange={e => changeRole(u.id, e.target.value)}
disabled={!!busy[u.id]}
>
<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 => toggleMute(u.id, e.target.checked)}
disabled={!!busy[u.id]}
/>
{u.muted ? 'Muted' : 'Active'}
</label>
) : (
<span className="muted">N/A</span>
)}
</td>
<td className="muted">{u.last_seen_at}</td>
<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>
))}
</tbody>
</table>
</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)
return (
<>
<tr>
<td>
<div className="user-cell">
<span className="user-handle">{handle}</span>
<span className="muted">
{fullName || u.display_name}
{u.email ? ` · ${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>
<td className="muted">{u.created_at || '—'}</td>
<td className="muted">{u.last_seen_at || '—'}</td>
</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}
</>
)
}
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>
)
}
+5 -2
View File
@@ -27,8 +27,11 @@ export default function BetaPending({ viewer }) {
{isPending ? (
<>
<p>
Thanks for telling us a bit about yourself. An admin will
review your request and get back to you as soon as we can.
Thanks for telling us a bit about yourself. The deployment's
admins are notified by email as soon as a request lands;
we don't commit to a fixed SLA turnaround depends on
operator availability and the deployment operator is
the right person to ask if a wait runs long.
</p>
<p>
While you wait, the catalog on the left lists every super-draft