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:
@@ -3,6 +3,7 @@ import { AuthProvider } from './contexts/AuthContext.jsx'
|
|||||||
import { SiteProvider } from './contexts/SiteContext.jsx'
|
import { SiteProvider } from './contexts/SiteContext.jsx'
|
||||||
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
import MaintenanceGate from './components/MaintenanceGate.jsx'
|
||||||
import RequireAuth from './components/RequireAuth.jsx'
|
import RequireAuth from './components/RequireAuth.jsx'
|
||||||
|
import RoleGate from './components/RoleGate.jsx'
|
||||||
|
|
||||||
// Public
|
// Public
|
||||||
import Portal from './routes/public/Portal.jsx'
|
import Portal from './routes/public/Portal.jsx'
|
||||||
@@ -31,6 +32,8 @@ import DiscordBotAdmin from './routes/admin/views/DiscordBotAdmin.jsx'
|
|||||||
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
import AuthProvidersAdmin from './routes/admin/views/AuthProvidersAdmin.jsx'
|
||||||
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
import UsersAdmin from './routes/admin/views/UsersAdmin.jsx'
|
||||||
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
import AccountAdmin from './routes/admin/views/AccountAdmin.jsx'
|
||||||
|
import Moderation from './routes/admin/views/Moderation.jsx'
|
||||||
|
import ModerationUser from './routes/admin/views/ModerationUser.jsx'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
@@ -73,6 +76,17 @@ export default function App() {
|
|||||||
<Route path="wiki" element={<WikiAdmin />} />
|
<Route path="wiki" element={<WikiAdmin />} />
|
||||||
<Route path="hero" element={<HeroEditor />} />
|
<Route path="hero" element={<HeroEditor />} />
|
||||||
<Route path="settings" element={<SettingsAdmin />} />
|
<Route path="settings" element={<SettingsAdmin />} />
|
||||||
|
<Route
|
||||||
|
path="moderation"
|
||||||
|
element={
|
||||||
|
<RoleGate roles={['admin', 'moderator']}>
|
||||||
|
<Outlet />
|
||||||
|
</RoleGate>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<Moderation />} />
|
||||||
|
<Route path="user/:discordId" element={<ModerationUser />} />
|
||||||
|
</Route>
|
||||||
<Route path="activity" element={<ActivityAdmin />} />
|
<Route path="activity" element={<ActivityAdmin />} />
|
||||||
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
<Route path="bot-activity" element={<BotActivityAdmin />} />
|
||||||
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
<Route path="discord-bot" element={<DiscordBotAdmin />} />
|
||||||
|
|||||||
@@ -120,6 +120,30 @@ export const api = {
|
|||||||
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }),
|
||||||
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
deleteUser: (id) => req(`/admin/users/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// ----- moderation dashboard (admin + moderator) -----
|
||||||
|
modSummary: () => req('/admin/moderation/stats/summary'),
|
||||||
|
modRecent: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.type) qs.set('type', params.type)
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/recent${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modSearch: (q) => req(`/admin/moderation/search?q=${encodeURIComponent(q)}`),
|
||||||
|
modUser: (discordId) => req(`/admin/moderation/user/${discordId}`),
|
||||||
|
modUserActions: (discordId, params = {}) => {
|
||||||
|
const qs = new URLSearchParams()
|
||||||
|
if (params.type) qs.set('type', params.type)
|
||||||
|
if (params.limit) qs.set('limit', params.limit)
|
||||||
|
if (params.offset) qs.set('offset', params.offset)
|
||||||
|
const s = qs.toString()
|
||||||
|
return req(`/admin/moderation/user/${discordId}/actions${s ? `?${s}` : ''}`)
|
||||||
|
},
|
||||||
|
modUserNotes: (discordId) => req(`/admin/moderation/user/${discordId}/notes`),
|
||||||
|
addModNote: (discordId, data) =>
|
||||||
|
req(`/admin/moderation/user/${discordId}/notes`, { method: 'POST', body: data }),
|
||||||
|
|
||||||
// ----- account security (self-service 2FA) -----
|
// ----- account security (self-service 2FA) -----
|
||||||
getAccount: () => req('/admin/account'),
|
getAccount: () => req('/admin/account'),
|
||||||
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }),
|
||||||
|
|||||||
11
client/src/components/RoleGate.jsx
Normal file
11
client/src/components/RoleGate.jsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Navigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext.jsx'
|
||||||
|
|
||||||
|
// Client-side role gate for admin sub-sections. Real enforcement is server-side
|
||||||
|
// (requireRole); this just keeps the UI honest — a user without one of `roles`
|
||||||
|
// is redirected rather than shown a page that will only 403 on every call.
|
||||||
|
export default function RoleGate({ roles, children, redirect = '/admin' }) {
|
||||||
|
const { user } = useAuth()
|
||||||
|
if (user && !roles.includes(user.role)) return <Navigate to={redirect} replace />
|
||||||
|
return children
|
||||||
|
}
|
||||||
@@ -4,11 +4,15 @@ import MoonDot from '../../components/MoonDot.jsx'
|
|||||||
import { useAuth } from '../../contexts/AuthContext.jsx'
|
import { useAuth } from '../../contexts/AuthContext.jsx'
|
||||||
import { useSite } from '../../contexts/SiteContext.jsx'
|
import { useSite } from '../../contexts/SiteContext.jsx'
|
||||||
|
|
||||||
|
// `roles` (when present) restricts which roles see a nav item. Items without it
|
||||||
|
// are shown to admin/editor as before. Moderators are further confined to just
|
||||||
|
// their own section + account security (see the redirect effect below).
|
||||||
const NAV = [
|
const NAV = [
|
||||||
{ to: '/admin', label: 'Dashboard', end: true },
|
{ to: '/admin', label: 'Dashboard', end: true },
|
||||||
{ to: '/admin/posts', label: 'Posts' },
|
{ to: '/admin/posts', label: 'Posts' },
|
||||||
{ to: '/admin/wiki', label: 'Wiki' },
|
{ to: '/admin/wiki', label: 'Wiki' },
|
||||||
{ to: '/admin/hero', label: 'Hero Editor' },
|
{ to: '/admin/hero', label: 'Hero Editor' },
|
||||||
|
{ to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] },
|
||||||
{ to: '/admin/settings', label: 'Settings' },
|
{ to: '/admin/settings', label: 'Settings' },
|
||||||
{ to: '/admin/activity', label: 'Activity' },
|
{ to: '/admin/activity', label: 'Activity' },
|
||||||
{ to: '/admin/bot-activity', label: 'Bot Activity' },
|
{ to: '/admin/bot-activity', label: 'Bot Activity' },
|
||||||
@@ -23,6 +27,7 @@ const TITLES = {
|
|||||||
'/admin/posts': 'Posts',
|
'/admin/posts': 'Posts',
|
||||||
'/admin/wiki': 'Wiki Pages',
|
'/admin/wiki': 'Wiki Pages',
|
||||||
'/admin/hero': 'Hero Editor',
|
'/admin/hero': 'Hero Editor',
|
||||||
|
'/admin/moderation': 'Moderation',
|
||||||
'/admin/settings': 'Site Settings',
|
'/admin/settings': 'Site Settings',
|
||||||
'/admin/activity': 'Activity Log',
|
'/admin/activity': 'Activity Log',
|
||||||
'/admin/bot-activity': 'Bot Activity',
|
'/admin/bot-activity': 'Bot Activity',
|
||||||
@@ -48,11 +53,31 @@ export default function AdminLayout() {
|
|||||||
const { mode } = useSite()
|
const { mode } = useSite()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const title = TITLES[location.pathname] || 'Admin'
|
const title =
|
||||||
|
TITLES[location.pathname] ||
|
||||||
|
(location.pathname.startsWith('/admin/moderation') ? 'Moderation' : 'Admin')
|
||||||
// The hero canvas editor needs room — let it use the full content width.
|
// The hero canvas editor needs room — let it use the full content width.
|
||||||
const wide = location.pathname === '/admin/hero'
|
const wide = location.pathname === '/admin/hero'
|
||||||
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
const modeDot = mode === 'live' ? 'var(--mode-live)' : 'var(--mode-maint)'
|
||||||
|
|
||||||
|
// Moderators only get the moderation section + their own account security.
|
||||||
|
const isModerator = user?.role === 'moderator'
|
||||||
|
const navItems = NAV.filter((n) => {
|
||||||
|
if (n.roles && !n.roles.includes(user?.role)) return false
|
||||||
|
if (isModerator) return n.to === '/admin/moderation' || n.to === '/admin/account'
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
// Confine a moderator who deep-links (or is redirected to the index) to a page
|
||||||
|
// outside their remit — the API would 403 anyway, so send them to their home.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isModerator) return
|
||||||
|
const p = location.pathname
|
||||||
|
if (!p.startsWith('/admin/moderation') && p !== '/admin/account') {
|
||||||
|
navigate('/admin/moderation', { replace: true })
|
||||||
|
}
|
||||||
|
}, [isModerator, location.pathname, navigate])
|
||||||
|
|
||||||
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
// Keep the admin out of search indexes (belt-and-suspenders with robots.txt).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const meta = document.createElement('meta')
|
const meta = document.createElement('meta')
|
||||||
@@ -94,7 +119,7 @@ export default function AdminLayout() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<nav style={{ flex: 1, padding: '14px 12px', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{NAV.map((n) => (
|
{navItems.map((n) => (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={n.to}
|
key={n.to}
|
||||||
to={n.to}
|
to={n.to}
|
||||||
|
|||||||
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)',
|
||||||
|
}
|
||||||
245
client/src/routes/admin/views/ModerationUser.jsx
Normal file
245
client/src/routes/admin/views/ModerationUser.jsx
Normal 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 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -83,6 +83,7 @@ export default function UserEditor({ user, onClose, onSaved }) {
|
|||||||
<select value={form.role} onChange={set('role')} className="select">
|
<select value={form.role} onChange={set('role')} className="select">
|
||||||
<option value="admin">admin</option>
|
<option value="admin">admin</option>
|
||||||
<option value="editor">editor</option>
|
<option value="editor">editor</option>
|
||||||
|
<option value="moderator">moderator</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { dateTime } from '../../../lib/format.js'
|
|||||||
import { api } from '../../../api/client.js'
|
import { api } from '../../../api/client.js'
|
||||||
import UserEditor from './UserEditor.jsx'
|
import UserEditor from './UserEditor.jsx'
|
||||||
|
|
||||||
|
const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator' }
|
||||||
|
|
||||||
export default function UsersAdmin() {
|
export default function UsersAdmin() {
|
||||||
const [tick, setTick] = useState(0)
|
const [tick, setTick] = useState(0)
|
||||||
const reload = useCallback(() => setTick((t) => t + 1), [])
|
const reload = useCallback(() => setTick((t) => t + 1), [])
|
||||||
@@ -16,7 +18,7 @@ export default function UsersAdmin() {
|
|||||||
<section>
|
<section>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 18, flexWrap: 'wrap', gap: 12 }}>
|
||||||
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
<p className="sans muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||||
Manage admin and editor accounts
|
Manage admin, editor, and moderator accounts
|
||||||
</p>
|
</p>
|
||||||
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
<button onClick={() => setEditing('new')} className="btn btn-primary btn-sq">
|
||||||
+ Add user
|
+ Add user
|
||||||
@@ -44,7 +46,7 @@ export default function UsersAdmin() {
|
|||||||
{u.username}
|
{u.username}
|
||||||
</td>
|
</td>
|
||||||
<td className="adm-td">
|
<td className="adm-td">
|
||||||
<span className={`badge ${u.role === 'admin' ? 'badge-admin' : 'badge-editor'}`}>{u.role}</span>
|
<span className={`badge ${ROLE_BADGE[u.role] || 'badge-editor'}`}>{u.role}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
<td className="adm-td dim">{u.last_login_at ? dateTime(u.last_login_at) : 'never'}</td>
|
||||||
<td className="adm-td" style={{ textAlign: 'right' }}>
|
<td className="adm-td" style={{ textAlign: 'right' }}>
|
||||||
|
|||||||
@@ -613,6 +613,29 @@ button[disabled] {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
.badge-moderator {
|
||||||
|
background: rgba(224, 176, 112, 0.12);
|
||||||
|
color: #e0b070;
|
||||||
|
border: 1px solid rgba(224, 176, 112, 0.4);
|
||||||
|
}
|
||||||
|
/* Action-type badges for the moderation dashboard. */
|
||||||
|
.badge-ban {
|
||||||
|
background: rgba(217, 139, 132, 0.16);
|
||||||
|
color: #d98b84;
|
||||||
|
border: 1px solid rgba(217, 139, 132, 0.4);
|
||||||
|
}
|
||||||
|
.badge-kick,
|
||||||
|
.badge-mute,
|
||||||
|
.badge-warn {
|
||||||
|
background: rgba(224, 176, 112, 0.12);
|
||||||
|
color: #e0b070;
|
||||||
|
border: 1px solid rgba(224, 176, 112, 0.4);
|
||||||
|
}
|
||||||
|
.badge-auto {
|
||||||
|
background: rgba(127, 153, 189, 0.14);
|
||||||
|
color: #9fb0c6;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
.link-accent {
|
.link-accent {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
username VARCHAR(32) NOT NULL UNIQUE,
|
username VARCHAR(32) NOT NULL UNIQUE,
|
||||||
password_hash VARCHAR(72) NOT NULL,
|
password_hash VARCHAR(72) NOT NULL,
|
||||||
role ENUM('admin','editor') NOT NULL DEFAULT 'admin',
|
role ENUM('admin','editor','moderator') NOT NULL DEFAULT 'admin',
|
||||||
totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA)
|
totp_secret VARCHAR(64) NULL, -- base32 TOTP secret (opt-in 2FA)
|
||||||
totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
totp_enabled TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
-- Any session token issued before this instant is rejected (see requireAuth).
|
-- Any session token issued before this instant is rejected (see requireAuth).
|
||||||
@@ -368,6 +368,25 @@ CREATE TABLE IF NOT EXISTS invite_log (
|
|||||||
INDEX idx_invite_log_guild (guild_id, created_at)
|
INDEX idx_invite_log_guild (guild_id, created_at)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Staff notes on a Discord user, surfaced in the admin moderation dashboard
|
||||||
|
-- (Phase 6). Unlike the tables above, this one is SERVER-owned — it is written
|
||||||
|
-- and read only by the main site (moderation.controller), never by the bot.
|
||||||
|
-- Keyed by discord_user_id (a snowflake, matching mod_actions.target_user_id) so
|
||||||
|
-- notes attach to a Discord identity even when it has no linked site account.
|
||||||
|
-- Notes are never user-visible; admin_only notes are further restricted to the
|
||||||
|
-- admin role (moderators see staff_only only) — enforced in the query layer.
|
||||||
|
CREATE TABLE IF NOT EXISTS mod_notes (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
discord_user_id VARCHAR(32) NOT NULL,
|
||||||
|
author_user_id INT NULL,
|
||||||
|
author_tag VARCHAR(120) NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
visibility ENUM('staff_only','admin_only') NOT NULL DEFAULT 'staff_only',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_mod_notes_author FOREIGN KEY (author_user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
INDEX idx_mod_notes_user (discord_user_id, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
-- Migrations for databases created before the wiki upgrade. Each statement uses
|
||||||
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
-- IF NOT EXISTS so re-running on every boot is a harmless no-op. New installs get
|
||||||
-- these columns from the CREATE TABLE above; existing installs get them here.
|
-- these columns from the CREATE TABLE above; existing installs get them here.
|
||||||
@@ -378,6 +397,10 @@ ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64) NULL;
|
|||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled TINYINT(1) NOT NULL DEFAULT 0;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled TINYINT(1) NOT NULL DEFAULT 0;
|
||||||
-- Session-revocation cutoff for databases created before token revocation landed.
|
-- Session-revocation cutoff for databases created before token revocation landed.
|
||||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS tokens_valid_after DATETIME NULL;
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS tokens_valid_after DATETIME NULL;
|
||||||
|
-- Moderation dashboard (Phase 6): add the 'moderator' role to databases created
|
||||||
|
-- before it. MODIFY has no IF NOT EXISTS form, but re-declaring the same ENUM is
|
||||||
|
-- an idempotent no-op, so it is safe to run on every boot.
|
||||||
|
ALTER TABLE users MODIFY COLUMN role ENUM('admin','editor','moderator') NOT NULL DEFAULT 'admin';
|
||||||
|
|
||||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS excerpt VARCHAR(400) NULL;
|
||||||
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
|
ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS category_id INT NULL;
|
||||||
|
|||||||
51
server/src/model/modNotes/modNotes.db.js
Normal file
51
server/src/model/modNotes/modNotes.db.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
// Staff notes on a Discord user (server-owned, see db/schema.sql mod_notes).
|
||||||
|
// Notes are never user-visible; admin_only notes are filtered out for non-admin
|
||||||
|
// callers at this layer via includeAdminOnly.
|
||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
async function listForUser(discordId, { includeAdminOnly = false } = {}) {
|
||||||
|
const visClause = includeAdminOnly ? '' : "AND n.visibility = 'staff_only'"
|
||||||
|
return query(
|
||||||
|
`SELECT n.id, n.discord_user_id, n.author_user_id, n.author_tag,
|
||||||
|
n.body, n.visibility, n.created_at,
|
||||||
|
u.username AS author_username
|
||||||
|
FROM mod_notes n
|
||||||
|
LEFT JOIN users u ON u.id = n.author_user_id
|
||||||
|
WHERE n.discord_user_id = ? ${visClause}
|
||||||
|
ORDER BY n.id DESC`,
|
||||||
|
[discordId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function insert({ discordUserId, authorUserId = null, authorTag = null, body, visibility = 'staff_only' }) {
|
||||||
|
const res = await query(
|
||||||
|
`INSERT INTO mod_notes (discord_user_id, author_user_id, author_tag, body, visibility)
|
||||||
|
VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
[discordUserId, authorUserId, authorTag, body, visibility],
|
||||||
|
)
|
||||||
|
return res.insertId
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getById(id) {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT n.id, n.discord_user_id, n.author_user_id, n.author_tag,
|
||||||
|
n.body, n.visibility, n.created_at,
|
||||||
|
u.username AS author_username
|
||||||
|
FROM mod_notes n
|
||||||
|
LEFT JOIN users u ON u.id = n.author_user_id
|
||||||
|
WHERE n.id = ? LIMIT 1`,
|
||||||
|
[id],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function countForUser(discordId, { includeAdminOnly = false } = {}) {
|
||||||
|
const visClause = includeAdminOnly ? '' : "AND visibility = 'staff_only'"
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT COUNT(*) AS c FROM mod_notes WHERE discord_user_id = ? ${visClause}`,
|
||||||
|
[discordId],
|
||||||
|
)
|
||||||
|
return Number(rows[0].c)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listForUser, insert, getById, countForUser }
|
||||||
18
server/src/model/modNotes/modNotes.model.js
Normal file
18
server/src/model/modNotes/modNotes.model.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const modNotesDb = require('./modNotes.db')
|
||||||
|
|
||||||
|
async function listForUser(discordId, { includeAdminOnly = false } = {}) {
|
||||||
|
return modNotesDb.listForUser(discordId, { includeAdminOnly })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function add({ discordUserId, author, body, visibility = 'staff_only' }) {
|
||||||
|
const id = await modNotesDb.insert({
|
||||||
|
discordUserId,
|
||||||
|
authorUserId: author ? author.id : null,
|
||||||
|
authorTag: author ? author.username : null,
|
||||||
|
body,
|
||||||
|
visibility,
|
||||||
|
})
|
||||||
|
return modNotesDb.getById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { listForUser, add }
|
||||||
117
server/src/model/moderation/moderation.db.js
Normal file
117
server/src/model/moderation/moderation.db.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
// Read-only access to the bot-owned moderation tables (mod_actions) for the
|
||||||
|
// admin moderation dashboard (Phase 6). These tables are normally owned by the
|
||||||
|
// bot process (bot/src/db.js) — see the comment in db/schema.sql — but they live
|
||||||
|
// in the same physical database, so the site reads them directly through the
|
||||||
|
// shared pool rather than round-tripping the bot over the internal API. This
|
||||||
|
// module NEVER writes them; all writes still belong to the bot.
|
||||||
|
//
|
||||||
|
// mod_actions is the single source of truth for ban/kick/mute/warn (every warn
|
||||||
|
// command also mirrors into `warnings`, so counting mod_actions avoids double
|
||||||
|
// counting). Accounts are correlated to Discord ids via user_identities
|
||||||
|
// (provider='discord', subject=<snowflake>), the same link the SSO flow writes.
|
||||||
|
const { query } = require('../../utils/db')
|
||||||
|
|
||||||
|
const TYPES = ['ban', 'kick', 'mute', 'warn']
|
||||||
|
|
||||||
|
// Per-type counts across three nested windows in a single scan. Boolean
|
||||||
|
// comparisons yield 1/0 in MariaDB, so SUM(created_at >= cutoff) counts the
|
||||||
|
// rows inside each window. Returns raw rows: [{ action_type, d1, d7, d30 }].
|
||||||
|
async function countsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
|
||||||
|
return query(
|
||||||
|
`SELECT action_type,
|
||||||
|
SUM(created_at >= ?) AS d1,
|
||||||
|
SUM(created_at >= ?) AS d7,
|
||||||
|
SUM(created_at >= ?) AS d30
|
||||||
|
FROM mod_actions
|
||||||
|
WHERE created_at >= ?
|
||||||
|
GROUP BY action_type`,
|
||||||
|
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACTION_SELECT = `
|
||||||
|
SELECT ma.id, ma.guild_id, ma.action_type,
|
||||||
|
ma.target_user_id, ma.target_tag,
|
||||||
|
ma.staff_user_id, ma.staff_tag,
|
||||||
|
ma.reason, ma.duration_seconds, ma.created_at,
|
||||||
|
ui.user_id AS target_site_user_id,
|
||||||
|
u.username AS target_site_username
|
||||||
|
FROM mod_actions ma
|
||||||
|
LEFT JOIN user_identities ui
|
||||||
|
ON ui.provider = 'discord' AND ui.subject = ma.target_user_id
|
||||||
|
LEFT JOIN users u ON u.id = ui.user_id`
|
||||||
|
|
||||||
|
// Most-recent-first action feed, optionally filtered by type. limit/offset
|
||||||
|
// pagination matching the activity-log convention.
|
||||||
|
async function recentActions({ type = null, limit = 50, offset = 0 } = {}) {
|
||||||
|
const where = type ? 'WHERE ma.action_type = ?' : ''
|
||||||
|
const params = type ? [type, limit, offset] : [limit, offset]
|
||||||
|
return query(`${ACTION_SELECT} ${where} ORDER BY ma.id DESC LIMIT ? OFFSET ?`, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full action history for one Discord user, optionally filtered by type.
|
||||||
|
async function userActions(discordId, { type = null, limit = 50, offset = 0 } = {}) {
|
||||||
|
const where = type
|
||||||
|
? 'WHERE ma.target_user_id = ? AND ma.action_type = ?'
|
||||||
|
: 'WHERE ma.target_user_id = ?'
|
||||||
|
const params = type ? [discordId, type, limit, offset] : [discordId, limit, offset]
|
||||||
|
return query(`${ACTION_SELECT} ${where} ORDER BY ma.id DESC LIMIT ? OFFSET ?`, params)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All-time per-type counts for one user.
|
||||||
|
async function userCounts(discordId) {
|
||||||
|
return query(
|
||||||
|
`SELECT action_type, COUNT(*) AS c FROM mod_actions
|
||||||
|
WHERE target_user_id = ? GROUP BY action_type`,
|
||||||
|
[discordId],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest username snapshot the bot recorded for this Discord id (usernames drift).
|
||||||
|
async function latestTag(discordId) {
|
||||||
|
const rows = await query(
|
||||||
|
'SELECT target_tag FROM mod_actions WHERE target_user_id = ? ORDER BY id DESC LIMIT 1',
|
||||||
|
[discordId],
|
||||||
|
)
|
||||||
|
return rows[0] ? rows[0].target_tag : null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linked site account for a Discord id, if any (via user_identities).
|
||||||
|
async function linkedAccount(discordId) {
|
||||||
|
const rows = await query(
|
||||||
|
`SELECT u.id, u.username, u.role
|
||||||
|
FROM user_identities ui
|
||||||
|
JOIN users u ON u.id = ui.user_id
|
||||||
|
WHERE ui.provider = 'discord' AND ui.subject = ?
|
||||||
|
LIMIT 1`,
|
||||||
|
[discordId],
|
||||||
|
)
|
||||||
|
return rows[0] || null
|
||||||
|
}
|
||||||
|
|
||||||
|
// User-lookup: match a Discord id exactly, or a username snapshot (target_tag)
|
||||||
|
// by prefix, returning the most recently seen distinct targets. Powers the
|
||||||
|
// dashboard search box (usernames drift, so we search historical snapshots too).
|
||||||
|
async function searchTargets(term, { limit = 20 } = {}) {
|
||||||
|
return query(
|
||||||
|
`SELECT ma.target_user_id, MAX(ma.target_tag) AS target_tag,
|
||||||
|
COUNT(*) AS action_count, MAX(ma.created_at) AS last_seen
|
||||||
|
FROM mod_actions ma
|
||||||
|
WHERE ma.target_user_id = ? OR ma.target_tag LIKE ?
|
||||||
|
GROUP BY ma.target_user_id
|
||||||
|
ORDER BY last_seen DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
[term, `${term}%`, limit],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TYPES,
|
||||||
|
countsByWindow,
|
||||||
|
recentActions,
|
||||||
|
userActions,
|
||||||
|
userCounts,
|
||||||
|
latestTag,
|
||||||
|
linkedAccount,
|
||||||
|
searchTargets,
|
||||||
|
}
|
||||||
72
server/src/model/moderation/moderation.model.js
Normal file
72
server/src/model/moderation/moderation.model.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
// Business logic for the moderation dashboard: reshapes the raw mod_actions
|
||||||
|
// reads into the shapes the admin UI consumes, and annotates each action with
|
||||||
|
// whether it was an automated (bot) action. For a Discord bot the application_id
|
||||||
|
// IS the bot's user id, and the filter/spam pipeline records automated actions
|
||||||
|
// with staff_user_id = the bot user (see bot/src/discord/messageFilter.js), so
|
||||||
|
// staff_user_id === bot_config.application_id reliably flags automated actions
|
||||||
|
// without needing new columns on mod_actions.
|
||||||
|
const moderationDb = require('./moderation.db')
|
||||||
|
const botConfigDb = require('../botConfig/botConfig.db')
|
||||||
|
const { zeroCounts, annotate, reshapeWindows } = require('./moderation.pure')
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
async function botApplicationId() {
|
||||||
|
try {
|
||||||
|
const cfg = await botConfigDb.get()
|
||||||
|
return cfg ? cfg.application_id : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts by type across 24h / 7d / 30d windows for the overview tiles.
|
||||||
|
async function summary() {
|
||||||
|
const now = Date.now()
|
||||||
|
const cutoff24h = new Date(now - DAY_MS)
|
||||||
|
const cutoff7d = new Date(now - 7 * DAY_MS)
|
||||||
|
const cutoff30d = new Date(now - 30 * DAY_MS)
|
||||||
|
|
||||||
|
const rows = await moderationDb.countsByWindow({ cutoff24h, cutoff7d, cutoff30d })
|
||||||
|
return reshapeWindows(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recent(opts) {
|
||||||
|
const appId = await botApplicationId()
|
||||||
|
return annotate(await moderationDb.recentActions(opts), appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userActions(discordId, opts) {
|
||||||
|
const appId = await botApplicationId()
|
||||||
|
return annotate(await moderationDb.userActions(discordId, opts), appId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header data for the per-user history page: latest known tag, linked site
|
||||||
|
// account (if any), and all-time counts per action type.
|
||||||
|
async function userSummary(discordId) {
|
||||||
|
const [countRows, tag, linked] = await Promise.all([
|
||||||
|
moderationDb.userCounts(discordId),
|
||||||
|
moderationDb.latestTag(discordId),
|
||||||
|
moderationDb.linkedAccount(discordId),
|
||||||
|
])
|
||||||
|
const counts = zeroCounts()
|
||||||
|
let total = 0
|
||||||
|
for (const row of countRows) {
|
||||||
|
const c = Number(row.c) || 0
|
||||||
|
if (counts[row.action_type] !== undefined) counts[row.action_type] = c
|
||||||
|
total += c
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
discord_user_id: discordId,
|
||||||
|
tag,
|
||||||
|
linked_account: linked,
|
||||||
|
counts,
|
||||||
|
total_actions: total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(term, opts) {
|
||||||
|
return moderationDb.searchTargets(term, opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { summary, recent, userActions, userSummary, search }
|
||||||
39
server/src/model/moderation/moderation.pure.js
Normal file
39
server/src/model/moderation/moderation.pure.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
// Pure reshaping/annotation helpers for the moderation dashboard, deliberately
|
||||||
|
// free of any DB (or other side-effecting) imports so they can be unit-tested
|
||||||
|
// without opening a database pool. moderation.model re-exports these.
|
||||||
|
|
||||||
|
function zeroCounts() {
|
||||||
|
return { ban: 0, kick: 0, mute: 0, warn: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag each action as automated (staff is the bot) and fold the joined
|
||||||
|
// user_identities columns into a linked_account object. The string coercion
|
||||||
|
// matters — snowflakes can arrive as number or string from different columns.
|
||||||
|
function annotate(rows, appId) {
|
||||||
|
return rows.map((r) => {
|
||||||
|
const isAutomated = appId != null && String(r.staff_user_id) === String(appId)
|
||||||
|
return {
|
||||||
|
...r,
|
||||||
|
is_automated: isAutomated,
|
||||||
|
linked_account: r.target_site_user_id
|
||||||
|
? { id: r.target_site_user_id, username: r.target_site_username }
|
||||||
|
: null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fold the per-type window rows into the { windows: { '24h', '7d', '30d' } }
|
||||||
|
// shape the dashboard tiles consume, zero-filling any type with no rows.
|
||||||
|
function reshapeWindows(rows) {
|
||||||
|
const windows = { '24h': zeroCounts(), '7d': zeroCounts(), '30d': zeroCounts() }
|
||||||
|
for (const row of rows) {
|
||||||
|
const t = row.action_type
|
||||||
|
if (windows['24h'][t] === undefined) continue
|
||||||
|
windows['24h'][t] = Number(row.d1) || 0
|
||||||
|
windows['7d'][t] = Number(row.d7) || 0
|
||||||
|
windows['30d'][t] = Number(row.d30) || 0
|
||||||
|
}
|
||||||
|
return { windows }
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { zeroCounts, annotate, reshapeWindows }
|
||||||
@@ -10,6 +10,7 @@ const account = require('./account.controller')
|
|||||||
const botActivity = require('./botActivity.controller')
|
const botActivity = require('./botActivity.controller')
|
||||||
const authProviders = require('./authProviders.controller')
|
const authProviders = require('./authProviders.controller')
|
||||||
const discordBot = require('./discordBot.controller')
|
const discordBot = require('./discordBot.controller')
|
||||||
|
const moderation = require('./moderation.controller')
|
||||||
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
const { isLoggedIn, requireRole } = require('../../../utils/auth')
|
||||||
const noindex = require('../../../middleware/noindex')
|
const noindex = require('../../../middleware/noindex')
|
||||||
const validate = require('../../../middleware/validate')
|
const validate = require('../../../middleware/validate')
|
||||||
@@ -23,6 +24,11 @@ adminRouter.use(noindex, isLoggedIn)
|
|||||||
// management, site mode, and settings are restricted to the admin role.
|
// management, site mode, and settings are restricted to the admin role.
|
||||||
const adminOnly = requireRole('admin')
|
const adminOnly = requireRole('admin')
|
||||||
|
|
||||||
|
// Moderation-dashboard gate. Moderators get the moderation views; admins can do
|
||||||
|
// everything a moderator can. Sensitive writes (admin_only notes) add an extra
|
||||||
|
// admin check inside the controller.
|
||||||
|
const modAccess = requireRole('admin', 'moderator')
|
||||||
|
|
||||||
// ── Account security (self-service, any logged-in role) ───────────────
|
// ── Account security (self-service, any logged-in role) ───────────────
|
||||||
// Not behind adminOnly: an editor manages their own 2FA too.
|
// Not behind adminOnly: an editor manages their own 2FA too.
|
||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
@@ -647,6 +653,70 @@ adminRouter.delete(
|
|||||||
authProviders.remove,
|
authProviders.remove,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ── Moderation dashboard (admin + moderator) ──────────────────────────
|
||||||
|
// Read-only views over the bot's mod_actions log, plus staff notes. The whole
|
||||||
|
// sub-path is gated for the moderator role (admins included).
|
||||||
|
adminRouter.use('/moderation', modAccess)
|
||||||
|
adminRouter.get(
|
||||||
|
'/moderation/stats/summary',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Moderation action counts for 24h/7d/30d (admin or moderator)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
moderation.getSummary,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/moderation/recent',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Recent moderation actions, optionally filtered by type'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
moderation.getRecent,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/moderation/search',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Look up moderated users by Discord id or username snapshot'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
moderation.search,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/moderation/user/:discordId',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Per-user moderation summary (counts, latest tag, linked account)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||||
|
validate,
|
||||||
|
moderation.getUser,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/moderation/user/:discordId/actions',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Full moderation action history for a user'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||||
|
validate,
|
||||||
|
moderation.getUserActions,
|
||||||
|
)
|
||||||
|
adminRouter.get(
|
||||||
|
'/moderation/user/:discordId/notes',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Staff notes for a user (admin_only notes hidden from moderators)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||||
|
validate,
|
||||||
|
moderation.getUserNotes,
|
||||||
|
)
|
||||||
|
adminRouter.post(
|
||||||
|
'/moderation/user/:discordId/notes',
|
||||||
|
// #swagger.tags = ['Admin · Moderation']
|
||||||
|
// #swagger.summary = 'Add a staff note (admin_only visibility requires the admin role)'
|
||||||
|
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
|
||||||
|
param('discordId').matches(/^[0-9]{1,32}$/),
|
||||||
|
body('body').isString().trim().isLength({ min: 1, max: 4000 }),
|
||||||
|
body('visibility').optional().isIn(['staff_only', 'admin_only']),
|
||||||
|
validate,
|
||||||
|
moderation.addUserNote,
|
||||||
|
)
|
||||||
|
|
||||||
// ── User management (admin only) ──────────────────────────────────────
|
// ── User management (admin only) ──────────────────────────────────────
|
||||||
adminRouter.use('/users', adminOnly)
|
adminRouter.use('/users', adminOnly)
|
||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
@@ -672,7 +742,7 @@ adminRouter.post(
|
|||||||
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
/* #swagger.responses[409] = { description: 'Username already taken', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
|
||||||
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
body('username').isString().trim().isLength({ min: 3, max: 32 }),
|
||||||
body('password').isString().isLength({ min: 8, max: 64 }),
|
body('password').isString().isLength({ min: 8, max: 64 }),
|
||||||
body('role').optional().isIn(['admin', 'editor']),
|
body('role').optional().isIn(['admin', 'editor', 'moderator']),
|
||||||
validate,
|
validate,
|
||||||
ctrl.createUser,
|
ctrl.createUser,
|
||||||
)
|
)
|
||||||
@@ -692,7 +762,7 @@ adminRouter.put(
|
|||||||
param('id').isInt(),
|
param('id').isInt(),
|
||||||
body('username').optional().isString().trim().isLength({ min: 3, max: 32 }),
|
body('username').optional().isString().trim().isLength({ min: 3, max: 32 }),
|
||||||
body('password').optional().isString().isLength({ min: 8, max: 64 }),
|
body('password').optional().isString().isLength({ min: 8, max: 64 }),
|
||||||
body('role').optional().isIn(['admin', 'editor']),
|
body('role').optional().isIn(['admin', 'editor', 'moderator']),
|
||||||
validate,
|
validate,
|
||||||
ctrl.updateUser,
|
ctrl.updateUser,
|
||||||
)
|
)
|
||||||
|
|||||||
133
server/src/router/v1/admin/moderation.controller.js
Normal file
133
server/src/router/v1/admin/moderation.controller.js
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// Admin moderation dashboard (Phase 6). Read-only views over the bot's
|
||||||
|
// mod_actions log plus server-owned staff notes. Mounted behind the
|
||||||
|
// admin+moderator RBAC gate (see admin.routes.js). The only mutation here is
|
||||||
|
// adding a staff note; admin_only notes are further restricted to the admin role.
|
||||||
|
const moderation = require('../../../model/moderation/moderation.model')
|
||||||
|
const modNotes = require('../../../model/modNotes/modNotes.model')
|
||||||
|
const modNotesDb = require('../../../model/modNotes/modNotes.db')
|
||||||
|
const activity = require('../../../model/activity/activity.model')
|
||||||
|
|
||||||
|
const log = require('../../../utils/logger')('moderation')
|
||||||
|
|
||||||
|
const VALID_TYPES = new Set(['ban', 'kick', 'mute', 'warn'])
|
||||||
|
const MAX_LIMIT = 200
|
||||||
|
const DEFAULT_LIMIT = 50
|
||||||
|
|
||||||
|
// Parse ?limit/&offset the same way the activity log does: numeric, capped.
|
||||||
|
function pageParams(req) {
|
||||||
|
const limit = Math.min(Number(req.query.limit) || DEFAULT_LIMIT, MAX_LIMIT)
|
||||||
|
const offset = Number(req.query.offset) || 0
|
||||||
|
return { limit, offset }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optional ?type filter — ignored unless it is a known action type.
|
||||||
|
function typeParam(req) {
|
||||||
|
const t = req.query.type
|
||||||
|
return VALID_TYPES.has(t) ? t : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAdmin(req) {
|
||||||
|
return req.user && req.user.role === 'admin'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getSummary(req, res) {
|
||||||
|
try {
|
||||||
|
return res.json(await moderation.summary())
|
||||||
|
} catch (err) {
|
||||||
|
log.error('summary failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRecent(req, res) {
|
||||||
|
try {
|
||||||
|
const { limit, offset } = pageParams(req)
|
||||||
|
return res.json(await moderation.recent({ type: typeParam(req), limit, offset }))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('recent failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function search(req, res) {
|
||||||
|
try {
|
||||||
|
const term = (req.query.q || '').trim()
|
||||||
|
if (!term) return res.json([])
|
||||||
|
return res.json(await moderation.search(term, { limit: 20 }))
|
||||||
|
} catch (err) {
|
||||||
|
log.error('search failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUser(req, res) {
|
||||||
|
try {
|
||||||
|
const summary = await moderation.userSummary(req.params.discordId)
|
||||||
|
const notesCount = await modNotesDb.countForUser(req.params.discordId, {
|
||||||
|
includeAdminOnly: isAdmin(req),
|
||||||
|
})
|
||||||
|
return res.json({ ...summary, notes_count: notesCount })
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getUser failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserActions(req, res) {
|
||||||
|
try {
|
||||||
|
const { limit, offset } = pageParams(req)
|
||||||
|
return res.json(
|
||||||
|
await moderation.userActions(req.params.discordId, { type: typeParam(req), limit, offset }),
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getUserActions failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserNotes(req, res) {
|
||||||
|
try {
|
||||||
|
const notes = await modNotes.listForUser(req.params.discordId, {
|
||||||
|
includeAdminOnly: isAdmin(req),
|
||||||
|
})
|
||||||
|
return res.json(notes)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('getUserNotes failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUserNote(req, res) {
|
||||||
|
try {
|
||||||
|
const visibility = req.body.visibility === 'admin_only' ? 'admin_only' : 'staff_only'
|
||||||
|
// admin_only notes can carry sensitive judgement calls — restrict to admins.
|
||||||
|
if (visibility === 'admin_only' && !isAdmin(req)) {
|
||||||
|
return res.status(403).json({ message: 'Only admins can add admin-only notes' })
|
||||||
|
}
|
||||||
|
const note = await modNotes.add({
|
||||||
|
discordUserId: req.params.discordId,
|
||||||
|
author: req.user,
|
||||||
|
body: req.body.body,
|
||||||
|
visibility,
|
||||||
|
})
|
||||||
|
await activity.log({
|
||||||
|
req,
|
||||||
|
action: 'moderation.note.add',
|
||||||
|
detail: { discordUserId: req.params.discordId, visibility },
|
||||||
|
})
|
||||||
|
return res.status(201).json(note)
|
||||||
|
} catch (err) {
|
||||||
|
log.error('addUserNote failed', { error: err.message })
|
||||||
|
return res.status(500).json({ message: 'Internal Server Error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getSummary,
|
||||||
|
getRecent,
|
||||||
|
search,
|
||||||
|
getUser,
|
||||||
|
getUserActions,
|
||||||
|
getUserNotes,
|
||||||
|
addUserNote,
|
||||||
|
}
|
||||||
@@ -3856,6 +3856,260 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/admin/moderation/stats/summary": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Moderation"
|
||||||
|
],
|
||||||
|
"summary": "Moderation action counts for 24h/7d/30d (admin or moderator)",
|
||||||
|
"description": "",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/moderation/recent": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Moderation"
|
||||||
|
],
|
||||||
|
"summary": "Recent moderation actions, optionally filtered by type",
|
||||||
|
"description": "",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/moderation/search": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Moderation"
|
||||||
|
],
|
||||||
|
"summary": "Look up moderated users by Discord id or username snapshot",
|
||||||
|
"description": "",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "q",
|
||||||
|
"in": "query",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/moderation/user/{discordId}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Moderation"
|
||||||
|
],
|
||||||
|
"summary": "Per-user moderation summary (counts, latest tag, linked account)",
|
||||||
|
"description": "",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "discordId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/moderation/user/{discordId}/actions": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Moderation"
|
||||||
|
],
|
||||||
|
"summary": "Full moderation action history for a user",
|
||||||
|
"description": "",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "discordId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/admin/moderation/user/{discordId}/notes": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Moderation"
|
||||||
|
],
|
||||||
|
"summary": "Staff notes for a user (admin_only notes hidden from moderators)",
|
||||||
|
"description": "",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "discordId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Admin · Moderation"
|
||||||
|
],
|
||||||
|
"summary": "Add a staff note (admin_only visibility requires the admin role)",
|
||||||
|
"description": "",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "discordId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Created"
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Forbidden"
|
||||||
|
},
|
||||||
|
"500": {
|
||||||
|
"description": "Internal Server Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"cookieAuth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"bearerAuth": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"visibility": {
|
||||||
|
"example": "any"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"example": "any"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/admin/users": {
|
"/api/v1/admin/users": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
73
server/test/moderation.test.js
Normal file
73
server/test/moderation.test.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
// Unit tests for the moderation dashboard's pure reshaping/annotation logic.
|
||||||
|
// DB-free (like the rest of this suite) — the SQL layer is exercised manually
|
||||||
|
// against a dev database per the plan's verification steps.
|
||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
|
||||||
|
const moderation = require('../src/model/moderation/moderation.pure')
|
||||||
|
|
||||||
|
test('reshapeWindows: folds rows into windows and zero-fills missing types', () => {
|
||||||
|
const rows = [
|
||||||
|
{ action_type: 'ban', d1: 1, d7: 3, d30: 5 },
|
||||||
|
{ action_type: 'warn', d1: 0, d7: 2, d30: 9 },
|
||||||
|
]
|
||||||
|
const { windows } = moderation.reshapeWindows(rows)
|
||||||
|
assert.deepEqual(windows['24h'], { ban: 1, kick: 0, mute: 0, warn: 0 })
|
||||||
|
assert.deepEqual(windows['7d'], { ban: 3, kick: 0, mute: 0, warn: 2 })
|
||||||
|
assert.deepEqual(windows['30d'], { ban: 5, kick: 0, mute: 0, warn: 9 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reshapeWindows: coerces string/decimal SUM results to numbers', () => {
|
||||||
|
const { windows } = moderation.reshapeWindows([{ action_type: 'mute', d1: '2', d7: '2', d30: '4' }])
|
||||||
|
assert.strictEqual(windows['24h'].mute, 2)
|
||||||
|
assert.strictEqual(windows['30d'].mute, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reshapeWindows: ignores unknown action types (e.g. future enum values)', () => {
|
||||||
|
const { windows } = moderation.reshapeWindows([{ action_type: 'filter_hit', d1: 9, d7: 9, d30: 9 }])
|
||||||
|
assert.deepEqual(windows['24h'], { ban: 0, kick: 0, mute: 0, warn: 0 })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('reshapeWindows: empty input yields all-zero windows', () => {
|
||||||
|
const { windows } = moderation.reshapeWindows([])
|
||||||
|
assert.deepEqual(windows, {
|
||||||
|
'24h': { ban: 0, kick: 0, mute: 0, warn: 0 },
|
||||||
|
'7d': { ban: 0, kick: 0, mute: 0, warn: 0 },
|
||||||
|
'30d': { ban: 0, kick: 0, mute: 0, warn: 0 },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('annotate: flags automated when staff id matches the bot application id', () => {
|
||||||
|
const [row] = moderation.annotate([{ staff_user_id: '999', target_site_user_id: null }], '999')
|
||||||
|
assert.equal(row.is_automated, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('annotate: string/number snowflake mismatch still matches (coerced)', () => {
|
||||||
|
// mod_actions stores staff_user_id as VARCHAR, but bot_config.application_id
|
||||||
|
// could arrive as a number — the compare must coerce both sides.
|
||||||
|
const [row] = moderation.annotate([{ staff_user_id: 999, target_site_user_id: null }], '999')
|
||||||
|
assert.equal(row.is_automated, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('annotate: staff action (id differs from bot) is not automated', () => {
|
||||||
|
const [row] = moderation.annotate([{ staff_user_id: '111', target_site_user_id: null }], '999')
|
||||||
|
assert.equal(row.is_automated, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('annotate: no bot application id configured means nothing is automated', () => {
|
||||||
|
const [row] = moderation.annotate([{ staff_user_id: '999', target_site_user_id: null }], null)
|
||||||
|
assert.equal(row.is_automated, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('annotate: folds joined identity columns into linked_account', () => {
|
||||||
|
const [row] = moderation.annotate(
|
||||||
|
[{ staff_user_id: '1', target_site_user_id: 7, target_site_username: 'perry' }],
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
assert.deepEqual(row.linked_account, { id: 7, username: 'perry' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('annotate: no linked identity yields null linked_account', () => {
|
||||||
|
const [row] = moderation.annotate([{ staff_user_id: '1', target_site_user_id: null }], null)
|
||||||
|
assert.equal(row.linked_account, null)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user