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:
234
client/src/routes/admin/views/Moderation.jsx
Normal file
234
client/src/routes/admin/views/Moderation.jsx
Normal 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)',
|
||||
}
|
||||
Reference in New Issue
Block a user