Files
website/client/src/routes/admin/views/ModerationUser.jsx
Claude b0c0d1fe9b 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
2026-07-05 10:16:34 -05:00

246 lines
8.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
)
}