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,245 @@
import { useCallback, useState } from 'react'
import { useParams, Link } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { dateTime, ago } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
import { useAuth } from '../../../contexts/AuthContext.jsx'
const ACTION_TABS = [
{ key: 'warn', label: 'Warnings' },
{ key: 'mute', label: 'Mutes' },
{ key: 'kick', label: 'Kicks' },
{ key: 'ban', label: 'Bans' },
]
function fmtDuration(seconds) {
if (!seconds) return null
if (seconds % 86400 === 0) return `${seconds / 86400}d`
if (seconds % 3600 === 0) return `${seconds / 3600}h`
if (seconds % 60 === 0) return `${seconds / 60}m`
return `${seconds}s`
}
export default function ModerationUser() {
const { discordId } = useParams()
const { user } = useAuth()
const isAdmin = user?.role === 'admin'
const [tab, setTab] = useState('warn')
const [tick, setTick] = useState(0)
const reload = useCallback(() => setTick((t) => t + 1), [])
const { loading, error, data } = useAsync(
() =>
Promise.all([
api.admin.modUser(discordId),
api.admin.modUserActions(discordId, { limit: 200 }),
api.admin.modUserNotes(discordId),
]),
[discordId, tick],
)
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load this users history." />
const [summary, actions, notes] = data
const counts = summary.counts || {}
const tabActions = actions.filter((a) => a.action_type === tab)
return (
<section>
<Link to="/admin/moderation" className="link-accent" style={{ fontSize: '0.85rem' }}>
Back to moderation
</Link>
{/* Header */}
<div style={{ padding: 22, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)', margin: '12px 0 20px' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)' }}>
{summary.tag || '(unknown user)'}
</span>
{summary.linked_account && (
<span className="badge badge-editor">site account: {summary.linked_account.username}</span>
)}
</div>
<div className="sans dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem', marginTop: 4 }}>
{discordId}
</div>
<div style={{ display: 'flex', gap: 18, marginTop: 14, flexWrap: 'wrap' }}>
{ACTION_TABS.map((t) => (
<Count key={t.key} label={t.label} value={counts[t.key] || 0} />
))}
<Count label="Notes" value={summary.notes_count || 0} />
</div>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 14, borderBottom: '1px solid var(--line-soft)', paddingBottom: 12 }}>
{ACTION_TABS.map((t) => (
<TabButton key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
{t.label} ({counts[t.key] || 0})
</TabButton>
))}
<TabButton active={tab === 'notes'} onClick={() => setTab('notes')}>
Notes ({summary.notes_count || 0})
</TabButton>
</div>
{tab === 'notes' ? (
<NotesTab discordId={discordId} notes={notes} isAdmin={isAdmin} onAdded={reload} />
) : (
<ActionTable rows={tabActions} showDuration={tab === 'mute'} />
)}
</section>
)
}
function Count({ label, value }) {
return (
<div>
<div className="display" style={{ fontSize: '1.4rem', color: 'var(--head)', lineHeight: 1 }}>{value}</div>
<div className="card-kicker" style={{ marginTop: 4, marginBottom: 0 }}>{label}</div>
</div>
)
}
function TabButton({ active, onClick, children }) {
return (
<button
onClick={onClick}
className="sans"
style={{
border: '1px solid var(--line)',
borderRadius: 8,
padding: '7px 14px',
cursor: 'pointer',
fontSize: '0.85rem',
background: active ? 'var(--blue)' : 'transparent',
color: active ? 'var(--ink)' : 'var(--muted)',
borderColor: active ? 'var(--accent)' : 'var(--line)',
}}
>
{children}
</button>
)
}
function ActionTable({ rows, showDuration }) {
return (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Reason</th>
<th className="adm-th">Actor</th>
{showDuration && <th className="adm-th">Duration</th>}
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td className="adm-td" colSpan={showDuration ? 4 : 3} style={{ color: 'var(--muted)' }}>
Nothing here.
</td>
</tr>
)}
{rows.map((a) => (
<tr key={a.id}>
<td className="adm-td" style={{ color: 'var(--text)' }}>{a.reason || '—'}</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>
{showDuration && <td className="adm-td dim">{fmtDuration(a.duration_seconds) || '—'}</td>}
<td className="adm-td dim" title={dateTime(a.created_at)}>{dateTime(a.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function NotesTab({ discordId, notes, isAdmin, onAdded }) {
const [body, setBody] = useState('')
const [visibility, setVisibility] = useState('staff_only')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
async function add() {
if (!body.trim()) return
setBusy(true)
setErr('')
try {
await api.admin.addModNote(discordId, { body: body.trim(), visibility })
setBody('')
setVisibility('staff_only')
onAdded()
} catch (e) {
setErr(e.message || 'Could not save the note.')
} finally {
setBusy(false)
}
}
return (
<div>
<div style={{ marginBottom: 18 }}>
{err && <p className="sans" style={{ margin: '0 0 8px', color: '#d98b84', fontSize: '0.85rem' }}>{err}</p>}
<textarea
className="textarea"
placeholder="Add a staff note about this user…"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={3}
style={{ width: '100%' }}
/>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' }}>
<select value={visibility} onChange={(e) => setVisibility(e.target.value)} className="select" style={{ maxWidth: 200 }}>
<option value="staff_only">Staff only</option>
{isAdmin && <option value="admin_only">Admin only</option>}
</select>
<button onClick={add} disabled={busy || !body.trim()} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Add note'}
</button>
</div>
</div>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Note</th>
<th className="adm-th">Author</th>
<th className="adm-th">Visibility</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{notes.length === 0 && (
<tr>
<td className="adm-td" colSpan={4} style={{ color: 'var(--muted)' }}>No notes yet.</td>
</tr>
)}
{notes.map((n) => (
<tr key={n.id}>
<td className="adm-td" style={{ color: 'var(--text)', whiteSpace: 'pre-wrap' }}>{n.body}</td>
<td className="adm-td dim">{n.author_username || n.author_tag || '—'}</td>
<td className="adm-td">
<span className={`badge ${n.visibility === 'admin_only' ? 'badge-ban' : 'badge-editor'}`}>
{n.visibility === 'admin_only' ? 'admin only' : 'staff'}
</span>
</td>
<td className="adm-td dim" title={dateTime(n.created_at)}>{ago(n.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}