Release 0.9.0: admin user-management page + new-request notifications
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user