Add moderation dashboard, user history & notes (Phase 6a)

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
This commit is contained in:
2026-07-05 10:16:34 -05:00
parent 20d3fbf594
commit b0c0d1fe9b
19 changed files with 1436 additions and 7 deletions

View File

@@ -0,0 +1,234 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
const WINDOWS = [
{ key: '24h', label: 'Last 24h' },
{ key: '7d', label: 'Last 7 days' },
{ key: '30d', label: 'Last 30 days' },
]
const TYPES = [
{ key: null, label: 'All' },
{ key: 'ban', label: 'Bans' },
{ key: 'kick', label: 'Kicks' },
{ key: 'mute', label: 'Mutes' },
{ key: 'warn', label: 'Warnings' },
]
const TILE_TYPES = [
{ key: 'ban', label: 'Bans' },
{ key: 'kick', label: 'Kicks' },
{ key: 'mute', label: 'Mutes' },
{ key: 'warn', label: 'Warnings' },
]
export default function Moderation() {
const navigate = useNavigate()
const [win, setWin] = useState('24h')
const [typeFilter, setTypeFilter] = useState(null)
const { loading, error, data } = useAsync(
() => Promise.all([api.admin.modSummary(), api.admin.modRecent({ limit: 100 })]),
[],
)
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load moderation data." />
const [summary, recent] = data
const counts = summary.windows?.[win] || { ban: 0, kick: 0, mute: 0, warn: 0 }
const feed = typeFilter ? recent.filter((r) => r.action_type === typeFilter) : recent
return (
<section>
<UserSearch onPick={(id) => navigate(`/admin/moderation/user/${id}`)} />
{/* Window selector */}
<div style={{ display: 'flex', gap: 8, margin: '4px 0 14px' }}>
{WINDOWS.map((w) => (
<button
key={w.key}
onClick={() => setWin(w.key)}
className="pill"
style={win === w.key ? activePill : undefined}
>
{w.label}
</button>
))}
</div>
{/* Stat tiles */}
<div className="grid-4" style={{ gap: 14, marginBottom: 12 }}>
{TILE_TYPES.map((t) => (
<button
key={t.key}
onClick={() => setTypeFilter(typeFilter === t.key ? null : t.key)}
style={{
textAlign: 'left',
padding: 20,
border: `1px solid ${typeFilter === t.key ? 'var(--accent)' : 'var(--line)'}`,
borderRadius: 12,
background: 'var(--panel-grad)',
cursor: 'pointer',
}}
>
<div className="display" style={{ fontSize: '2rem', color: 'var(--head)', lineHeight: 1 }}>
{counts[t.key] ?? 0}
</div>
<div className="card-kicker" style={{ marginTop: 8, marginBottom: 0 }}>
{t.label}
</div>
</button>
))}
</div>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 24px' }}>
Joins / leaves, filter hits, spam hits, and invite usage aren’t tracked yet — they arrive
when bot event capture lands (Phase 6b).
</p>
{/* Recent activity feed */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }}>
<h2 className="display" style={{ margin: 0, fontSize: '1.25rem', color: 'var(--head)' }}>
Recent actions
</h2>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{TYPES.map((t) => (
<button
key={t.label}
onClick={() => setTypeFilter(t.key)}
className="pill"
style={typeFilter === t.key ? activePill : undefined}
>
{t.label}
</button>
))}
</div>
</div>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Action</th>
<th className="adm-th">Target</th>
<th className="adm-th">Staff</th>
<th className="adm-th">Reason</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{feed.length === 0 && (
<tr>
<td className="adm-td" colSpan={5} style={{ color: 'var(--muted)' }}>
No matching actions.
</td>
</tr>
)}
{feed.map((a) => (
<tr key={a.id}>
<td className="adm-td">
<span className={`badge badge-${a.action_type}`}>{a.action_type}</span>
</td>
<td className="adm-td">
<span
className="link-accent"
onClick={() => navigate(`/admin/moderation/user/${a.target_user_id}`)}
>
{a.target_tag || a.target_user_id}
</span>
{a.linked_account && (
<span className="badge badge-editor" style={{ marginLeft: 8 }}>
site: {a.linked_account.username}
</span>
)}
</td>
<td className="adm-td">
{a.is_automated ? (
<span className="badge badge-auto">Automated</span>
) : (
<span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</span>
)}
</td>
<td className="adm-td" style={{ color: 'var(--muted)', maxWidth: 280 }}>
{a.reason || '—'}
</td>
<td className="adm-td dim" title={dateTime(a.created_at)}>
{ago(a.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)
}
// User lookup: search by Discord id or a historical username snapshot.
function UserSearch({ onPick }) {
const [term, setTerm] = useState('')
const [results, setResults] = useState(null)
const [busy, setBusy] = useState(false)
async function run(e) {
e.preventDefault()
const q = term.trim()
if (!q) return
setBusy(true)
try {
const rows = await api.admin.modSearch(q)
setResults(rows)
} finally {
setBusy(false)
}
}
return (
<div style={{ marginBottom: 22 }}>
<form onSubmit={run} style={{ display: 'flex', gap: 8 }}>
<input
className="input"
placeholder="Search by Discord ID or username…"
value={term}
onChange={(e) => setTerm(e.target.value)}
style={{ maxWidth: 360 }}
/>
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>
{busy ? 'Searching…' : 'Look up'}
</button>
</form>
{results && results.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>
No moderated users match “{term}”.
</p>
)}
{results && results.length > 0 && (
<div className="panel-flat" style={{ marginTop: 10 }}>
<table className="adm-table">
<tbody>
{results.map((r) => (
<tr key={r.target_user_id} style={{ cursor: 'pointer' }} onClick={() => onPick(r.target_user_id)}>
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.target_tag || '(unknown tag)'}</td>
<td className="adm-td dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem' }}>
{r.target_user_id}
</td>
<td className="adm-td dim">{r.action_count} action{Number(r.action_count) === 1 ? '' : 's'}</td>
<td className="adm-td dim">last {ago(r.last_seen)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
const activePill = {
background: 'var(--blue)',
color: 'var(--ink)',
borderColor: 'var(--accent)',
}