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
246 lines
8.5 KiB
JavaScript
246 lines
8.5 KiB
JavaScript
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 user’s 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>
|
||
)
|
||
}
|