Light up the moderation dashboard's previously-empty widgets by persisting the
event streams the bot only reacted to in-memory before.
Schema (bot-owned)
- member_events: join/leave, with invite_code/inviter_* for best-effort invite
attribution on joins
- filter_hits: word / foreign-invite filter deletions (matched + action_taken)
- spam_hits: rate_limit / mass_mention / mass_emoji detections
Bot
- new models memberEvents/filterHits/spamHits
- guildMemberAdd records the join with invite attribution; new inviteTracker.js
keeps an invite-use cache (GuildInvites intent + inviteCreate/inviteDelete) and
diffs it on join to find which invite was used — best-effort, never blocks
auto-role
- new guildMemberRemove records leaves
- messageFilter records filter/spam hits alongside the existing warn/mute;
inviteFilter now returns the offending code; detectSpam identifies which spam
rule tripped (preserving the rate-limit-first side-effect order)
- mod_actions still logs the resulting warn/mute — the new tables are additive
Server
- summary extended with joins/leaves/invite_joins/filter_hits/spam_hits per window
- new feeds: /api/v1/admin/moderation/{members,filter-hits,spam-hits}
Client
- overview now shows 8 tiles (mod actions + joins/leaves/filter/spam, joins tile
notes "N via invite") plus an Events panel with Members/Filter/Spam tabs;
removed the coming-soon note
Verified: 119 server unit tests, client build, 14-check DB-backed smoke, and a
browser click-through of every tile and events tab (incl. invite attribution).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV
331 lines
13 KiB
JavaScript
331 lines
13 KiB
JavaScript
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 MOD_TILES = [
|
|
{ key: 'ban', label: 'Bans' },
|
|
{ key: 'kick', label: 'Kicks' },
|
|
{ key: 'mute', label: 'Mutes' },
|
|
{ key: 'warn', label: 'Warnings' },
|
|
]
|
|
// Second tile row → jumps the events panel to the matching stream.
|
|
const EVENT_TILES = [
|
|
{ key: 'joins', label: 'Joins', tab: 'members' },
|
|
{ key: 'leaves', label: 'Leaves', tab: 'members' },
|
|
{ key: 'filter_hits', label: 'Filter hits', tab: 'filter' },
|
|
{ key: 'spam_hits', label: 'Spam hits', tab: 'spam' },
|
|
]
|
|
const EVENT_TABS = [
|
|
{ key: 'members', label: 'Members' },
|
|
{ key: 'filter', label: 'Filter hits' },
|
|
{ key: 'spam', label: 'Spam hits' },
|
|
]
|
|
|
|
export default function Moderation() {
|
|
const navigate = useNavigate()
|
|
const [win, setWin] = useState('24h')
|
|
const [typeFilter, setTypeFilter] = useState(null)
|
|
const [eventTab, setEventTab] = useState('members')
|
|
|
|
const { loading, error, data } = useAsync(
|
|
() =>
|
|
Promise.all([
|
|
api.admin.modSummary(),
|
|
api.admin.modRecent({ limit: 100 }),
|
|
api.admin.modMembers({ limit: 50 }),
|
|
api.admin.modFilterHits({ limit: 50 }),
|
|
api.admin.modSpamHits({ limit: 50 }),
|
|
]),
|
|
[],
|
|
)
|
|
|
|
if (loading) return <Loading />
|
|
if (error) return <ErrorState message="Could not load moderation data." />
|
|
|
|
const [summary, recent, members, filterHits, spamHits] = data
|
|
const counts = summary.windows?.[win] || {}
|
|
const feed = typeFilter ? recent.filter((r) => r.action_type === typeFilter) : recent
|
|
const goUser = (id) => navigate(`/admin/moderation/user/${id}`)
|
|
|
|
return (
|
|
<section>
|
|
<UserSearch onPick={goUser} />
|
|
|
|
{/* 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>
|
|
|
|
{/* Moderation-action tiles (click filters the recent-actions feed) */}
|
|
<div className="grid-4" style={{ gap: 14, marginBottom: 14 }}>
|
|
{MOD_TILES.map((t) => (
|
|
<Tile
|
|
key={t.key}
|
|
value={counts[t.key] ?? 0}
|
|
label={t.label}
|
|
active={typeFilter === t.key}
|
|
onClick={() => setTypeFilter(typeFilter === t.key ? null : t.key)}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Event tiles (click jumps the events panel to that stream) */}
|
|
<div className="grid-4" style={{ gap: 14, marginBottom: 8 }}>
|
|
{EVENT_TILES.map((t) => (
|
|
<Tile
|
|
key={t.key}
|
|
value={counts[t.key] ?? 0}
|
|
label={t.label}
|
|
sub={t.key === 'joins' && counts.invite_joins ? `${counts.invite_joins} via invite` : null}
|
|
active={eventTab === t.tab}
|
|
onClick={() => setEventTab(t.tab)}
|
|
/>
|
|
))}
|
|
</div>
|
|
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 24px' }}>
|
|
Counts are for the selected window. Member, filter, and spam events are captured live by the bot.
|
|
</p>
|
|
|
|
{/* Recent moderation actions */}
|
|
<div style={rowHead}>
|
|
<h2 className="display" style={h2}>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" style={{ marginBottom: 30 }}>
|
|
<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={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={() => goUser(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>
|
|
|
|
{/* Event streams panel */}
|
|
<div style={rowHead}>
|
|
<h2 className="display" style={h2}>Events</h2>
|
|
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
|
{EVENT_TABS.map((t) => (
|
|
<button key={t.key} onClick={() => setEventTab(t.key)} className="pill" style={eventTab === t.key ? activePill : undefined}>
|
|
{t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{eventTab === 'members' && <MembersTable rows={members} onUser={goUser} />}
|
|
{eventTab === 'filter' && <FilterTable rows={filterHits} onUser={goUser} />}
|
|
{eventTab === 'spam' && <SpamTable rows={spamHits} onUser={goUser} />}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function Tile({ value, label, sub, active, onClick }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
style={{
|
|
textAlign: 'left',
|
|
padding: 20,
|
|
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
|
|
borderRadius: 12,
|
|
background: 'var(--panel-grad)',
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
<div className="display" style={{ fontSize: '2rem', color: 'var(--head)', lineHeight: 1 }}>{value}</div>
|
|
<div className="card-kicker" style={{ marginTop: 8, marginBottom: 0 }}>{label}</div>
|
|
{sub && <div className="sans dim" style={{ fontSize: '0.68rem', marginTop: 4 }}>{sub}</div>}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function MembersTable({ rows, onUser }) {
|
|
return (
|
|
<div className="panel-flat">
|
|
<table className="adm-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="adm-th">Event</th>
|
|
<th className="adm-th">User</th>
|
|
<th className="adm-th">Invite</th>
|
|
<th className="adm-th">When</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.length === 0 && <tr><td className="adm-td" colSpan={4} style={muted}>No member events yet.</td></tr>}
|
|
{rows.map((m) => (
|
|
<tr key={m.id}>
|
|
<td className="adm-td"><span className={`badge ${m.event_type === 'join' ? 'badge-pub' : 'badge-ban'}`}>{m.event_type}</span></td>
|
|
<td className="adm-td"><span className="link-accent" onClick={() => onUser(m.discord_user_id)}>{m.username || m.discord_user_id}</span></td>
|
|
<td className="adm-td dim">
|
|
{m.invite_code ? (
|
|
<span>{m.invite_code}{m.inviter_tag ? ` · by ${m.inviter_tag}` : ''}</span>
|
|
) : '—'}
|
|
</td>
|
|
<td className="adm-td dim" title={dateTime(m.created_at)}>{ago(m.created_at)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function FilterTable({ rows, onUser }) {
|
|
return (
|
|
<div className="panel-flat">
|
|
<table className="adm-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="adm-th">Type</th>
|
|
<th className="adm-th">User</th>
|
|
<th className="adm-th">Matched</th>
|
|
<th className="adm-th">Action</th>
|
|
<th className="adm-th">When</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.length === 0 && <tr><td className="adm-td" colSpan={5} style={muted}>No filter hits yet.</td></tr>}
|
|
{rows.map((f) => (
|
|
<tr key={f.id}>
|
|
<td className="adm-td"><span className={`badge ${f.hit_type === 'invite' ? 'badge-ban' : 'badge-warn'}`}>{f.hit_type}</span></td>
|
|
<td className="adm-td"><span className="link-accent" onClick={() => onUser(f.discord_user_id)}>{f.username || f.discord_user_id}</span></td>
|
|
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 240 }}>{f.matched || '—'}</td>
|
|
<td className="adm-td"><span className={`badge badge-${f.action_taken === 'delete' ? 'auto' : f.action_taken}`}>{f.action_taken}</span></td>
|
|
<td className="adm-td dim" title={dateTime(f.created_at)}>{ago(f.created_at)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const SPAM_LABEL = { rate_limit: 'Rate limit', mass_mention: 'Mass mention', mass_emoji: 'Mass emoji' }
|
|
|
|
function SpamTable({ rows, onUser }) {
|
|
return (
|
|
<div className="panel-flat">
|
|
<table className="adm-table">
|
|
<thead>
|
|
<tr>
|
|
<th className="adm-th">Type</th>
|
|
<th className="adm-th">User</th>
|
|
<th className="adm-th">When</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.length === 0 && <tr><td className="adm-td" colSpan={3} style={muted}>No spam hits yet.</td></tr>}
|
|
{rows.map((s) => (
|
|
<tr key={s.id}>
|
|
<td className="adm-td"><span className="badge badge-warn">{SPAM_LABEL[s.spam_type] || s.spam_type}</span></td>
|
|
<td className="adm-td"><span className="link-accent" onClick={() => onUser(s.discord_user_id)}>{s.username || s.discord_user_id}</span></td>
|
|
<td className="adm-td dim" title={dateTime(s.created_at)}>{ago(s.created_at)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// 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 {
|
|
setResults(await api.admin.modSearch(q))
|
|
} 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)' }
|
|
const rowHead = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }
|
|
const h2 = { margin: 0, fontSize: '1.25rem', color: 'var(--head)' }
|
|
const muted = { color: 'var(--muted)' }
|