Surface the Discord bot's moderation data on the admin panel: a read-only
staff dashboard over the existing mod_actions log, per-user history, staff
notes, and a new moderator role. No bot changes.
Schema
- users.role ENUM gains 'moderator' (CREATE + idempotent ALTER for existing DBs)
- new server-owned mod_notes table (staff_only/admin_only visibility)
Server
- model/moderation: read mod_actions via the shared pool (documented read-only
cross of the bot/server ownership boundary), correlate accounts through
user_identities (provider='discord'), flag automated actions via
staff_user_id === bot_config.application_id; pure reshaping helpers isolated
in moderation.pure.js so they unit-test without opening a DB pool
- model/modNotes: list/add with role-gated admin_only visibility
- admin/moderation.controller + routes under /api/v1/admin/moderation/* gated by
requireRole('admin','moderator'); admin_only note writes require admin
- allow assigning 'moderator' in the user create/update validators
Client
- /admin/moderation overview (window tiles, type-filterable recent feed, user
lookup) and /user/:discordId history (tabs + notes with add-note)
- RoleGate; AdminLayout filters nav and confines moderators to their section
- moderator badge + action-type/auto badges
Deferred (see plan): 6b bot event capture (joins/leaves/filter/spam), 6c appeals
(needs public accounts), 6d /internal/mod-reverse bot reversal callback.
Verified: 116 server unit tests, client build, DB-backed model smoke, full
HTTP/RBAC e2e, and a browser click-through of the dashboard.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
77 lines
2.7 KiB
JavaScript
77 lines
2.7 KiB
JavaScript
import { useCallback, useState } from 'react'
|
|
import { Loading, ErrorState } from '../../../components/PageState.jsx'
|
|
import { useAsync } from '../../../lib/useAsync.js'
|
|
import { dateTime } from '../../../lib/format.js'
|
|
import { api } from '../../../api/client.js'
|
|
import UserEditor from './UserEditor.jsx'
|
|
|
|
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator' }
|
|
|
|
export default function UsersAdmin() {
|
|
const [tick, setTick] = useState(0)
|
|
const reload = useCallback(() => setTick((t) => t + 1), [])
|
|
const { loading, error, data } = useAsync(() => api.admin.listUsers(), [tick])
|
|
const [editing, setEditing] = useState(null) // null | 'new' | user
|
|
const users = data || []
|
|
|
|
return (
|
|
<section>
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
|
|
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
|
Manage admin, editor, and moderator accounts
|
|
</p>
|
|
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
|
+ Add user
|
|
</button>
|
|
</div>
|
|
|
|
{loading && <Loading />}
|
|
{error && <ErrorState message="Could not load users." />}
|
|
|
|
{!loading && !error && (
|
|
<div className="panel-flat">
|
|
<table className="adm-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="adm-th">Username</th>
|
|
<th className="adm-th">Role</th>
|
|
<th className="adm-th">Last login</th>
|
|
<th className="adm-th" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{users.map((u) => (
|
|
<tr key={u.id}>
|
|
<td className="adm-td" style={{ color: 'var(--head)' }}>
|
|
{u.username}
|
|
</td>
|
|
<td className="adm-td">
|
|
<span className={`badge ${ROLE_BADGE[u.role] || 'badge-editor'}`}>{u.role}</span>
|
|
</td>
|
|
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
|
<td className="adm-td" style={{ textAlign: 'right' }}>
|
|
<span className="link-accent" onClick={() => setEditing(u)}>
|
|
Edit
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{editing && (
|
|
<UserEditor
|
|
user={editing === 'new' ? null : editing}
|
|
onClose={() => setEditing(null)}
|
|
onSaved={() => {
|
|
setEditing(null)
|
|
reload()
|
|
}}
|
|
/>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|