diff --git a/client/src/App.jsx b/client/src/App.jsx index 2796c51..9deccb3 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -3,6 +3,7 @@ import { AuthProvider } from './contexts/AuthContext.jsx' import { SiteProvider } from './contexts/SiteContext.jsx' import MaintenanceGate from './components/MaintenanceGate.jsx' import RequireAuth from './components/RequireAuth.jsx' +import RoleGate from './components/RoleGate.jsx' // Public 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 UsersAdmin from './routes/admin/views/UsersAdmin.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() { return ( @@ -73,6 +76,17 @@ export default function App() { } /> } /> } /> + + + + } + > + } /> + } /> + } /> } /> } /> diff --git a/client/src/api/client.js b/client/src/api/client.js index 1c87b58..be66a86 100644 --- a/client/src/api/client.js +++ b/client/src/api/client.js @@ -120,6 +120,30 @@ export const api = { updateUser: (id, data) => req(`/admin/users/${id}`, { method: 'PUT', body: data }), 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) ----- getAccount: () => req('/admin/account'), totpSetup: () => req('/admin/account/totp/setup', { method: 'POST' }), diff --git a/client/src/components/RoleGate.jsx b/client/src/components/RoleGate.jsx new file mode 100644 index 0000000..bd4df0a --- /dev/null +++ b/client/src/components/RoleGate.jsx @@ -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 + return children +} diff --git a/client/src/routes/admin/AdminLayout.jsx b/client/src/routes/admin/AdminLayout.jsx index ac0c82c..2a2e90b 100644 --- a/client/src/routes/admin/AdminLayout.jsx +++ b/client/src/routes/admin/AdminLayout.jsx @@ -4,11 +4,15 @@ import MoonDot from '../../components/MoonDot.jsx' import { useAuth } from '../../contexts/AuthContext.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 = [ { to: '/admin', label: 'Dashboard', end: true }, { to: '/admin/posts', label: 'Posts' }, { to: '/admin/wiki', label: 'Wiki' }, { to: '/admin/hero', label: 'Hero Editor' }, + { to: '/admin/moderation', label: 'Moderation', roles: ['admin', 'moderator'] }, { to: '/admin/settings', label: 'Settings' }, { to: '/admin/activity', label: 'Activity' }, { to: '/admin/bot-activity', label: 'Bot Activity' }, @@ -23,6 +27,7 @@ const TITLES = { '/admin/posts': 'Posts', '/admin/wiki': 'Wiki Pages', '/admin/hero': 'Hero Editor', + '/admin/moderation': 'Moderation', '/admin/settings': 'Site Settings', '/admin/activity': 'Activity Log', '/admin/bot-activity': 'Bot Activity', @@ -48,11 +53,31 @@ export default function AdminLayout() { const { mode } = useSite() const navigate = useNavigate() 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. const wide = location.pathname === '/admin/hero' 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). useEffect(() => { const meta = document.createElement('meta') @@ -94,7 +119,7 @@ export default function AdminLayout() { - {NAV.map((n) => ( + {navItems.map((n) => ( Promise.all([api.admin.modSummary(), api.admin.modRecent({ limit: 100 })]), + [], + ) + + if (loading) return + if (error) return + + 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 ( + + navigate(`/admin/moderation/user/${id}`)} /> + + {/* Window selector */} + + {WINDOWS.map((w) => ( + setWin(w.key)} + className="pill" + style={win === w.key ? activePill : undefined} + > + {w.label} + + ))} + + + {/* Stat tiles */} + + {TILE_TYPES.map((t) => ( + 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', + }} + > + + {counts[t.key] ?? 0} + + + {t.label} + + + ))} + + + + Joins / leaves, filter hits, spam hits, and invite usage aren’t tracked yet — they arrive + when bot event capture lands (Phase 6b). + + + {/* Recent activity feed */} + + + Recent actions + + + {TYPES.map((t) => ( + setTypeFilter(t.key)} + className="pill" + style={typeFilter === t.key ? activePill : undefined} + > + {t.label} + + ))} + + + + + + + + Action + Target + Staff + Reason + When + + + + {feed.length === 0 && ( + + + No matching actions. + + + )} + {feed.map((a) => ( + + + {a.action_type} + + + navigate(`/admin/moderation/user/${a.target_user_id}`)} + > + {a.target_tag || a.target_user_id} + + {a.linked_account && ( + + site: {a.linked_account.username} + + )} + + + {a.is_automated ? ( + Automated + ) : ( + {a.staff_tag || a.staff_user_id} + )} + + + {a.reason || '—'} + + + {ago(a.created_at)} + + + ))} + + + + + ) +} + +// 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 ( + + + setTerm(e.target.value)} + style={{ maxWidth: 360 }} + /> + + {busy ? 'Searching…' : 'Look up'} + + + {results && results.length === 0 && ( + + No moderated users match “{term}”. + + )} + {results && results.length > 0 && ( + + + + {results.map((r) => ( + onPick(r.target_user_id)}> + {r.target_tag || '(unknown tag)'} + + {r.target_user_id} + + {r.action_count} action{Number(r.action_count) === 1 ? '' : 's'} + last {ago(r.last_seen)} + + ))} + + + + )} + + ) +} + +const activePill = { + background: 'var(--blue)', + color: 'var(--ink)', + borderColor: 'var(--accent)', +} diff --git a/client/src/routes/admin/views/ModerationUser.jsx b/client/src/routes/admin/views/ModerationUser.jsx new file mode 100644 index 0000000..a8c127c --- /dev/null +++ b/client/src/routes/admin/views/ModerationUser.jsx @@ -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 + if (error) return + + const [summary, actions, notes] = data + const counts = summary.counts || {} + const tabActions = actions.filter((a) => a.action_type === tab) + + return ( + + + ← Back to moderation + + + {/* Header */} + + + + {summary.tag || '(unknown user)'} + + {summary.linked_account && ( + site account: {summary.linked_account.username} + )} + + + {discordId} + + + {ACTION_TABS.map((t) => ( + + ))} + + + + + {/* Tabs */} + + {ACTION_TABS.map((t) => ( + setTab(t.key)}> + {t.label} ({counts[t.key] || 0}) + + ))} + setTab('notes')}> + Notes ({summary.notes_count || 0}) + + + + {tab === 'notes' ? ( + + ) : ( + + )} + + ) +} + +function Count({ label, value }) { + return ( + + {value} + {label} + + ) +} + +function TabButton({ active, onClick, children }) { + return ( + + {children} + + ) +} + +function ActionTable({ rows, showDuration }) { + return ( + + + + + Reason + Actor + {showDuration && Duration} + When + + + + {rows.length === 0 && ( + + + Nothing here. + + + )} + {rows.map((a) => ( + + {a.reason || '—'} + + {a.is_automated ? ( + Automated + ) : ( + {a.staff_tag || a.staff_user_id} + )} + + {showDuration && {fmtDuration(a.duration_seconds) || '—'}} + {dateTime(a.created_at)} + + ))} + + + + ) +} + +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 ( + + + {err && {err}} + setBody(e.target.value)} + rows={3} + style={{ width: '100%' }} + /> + + setVisibility(e.target.value)} className="select" style={{ maxWidth: 200 }}> + Staff only + {isAdmin && Admin only} + + + {busy ? 'Saving…' : 'Add note'} + + + + + + + + + Note + Author + Visibility + When + + + + {notes.length === 0 && ( + + No notes yet. + + )} + {notes.map((n) => ( + + {n.body} + {n.author_username || n.author_tag || '—'} + + + {n.visibility === 'admin_only' ? 'admin only' : 'staff'} + + + {ago(n.created_at)} + + ))} + + + + + ) +} diff --git a/client/src/routes/admin/views/UserEditor.jsx b/client/src/routes/admin/views/UserEditor.jsx index 2c1cd87..b15f53c 100644 --- a/client/src/routes/admin/views/UserEditor.jsx +++ b/client/src/routes/admin/views/UserEditor.jsx @@ -83,6 +83,7 @@ export default function UserEditor({ user, onClose, onSaved }) { admin editor + moderator diff --git a/client/src/routes/admin/views/UsersAdmin.jsx b/client/src/routes/admin/views/UsersAdmin.jsx index 36a50a5..a92ab67 100644 --- a/client/src/routes/admin/views/UsersAdmin.jsx +++ b/client/src/routes/admin/views/UsersAdmin.jsx @@ -5,6 +5,8 @@ import { dateTime } from '../../../lib/format.js' import { api } from '../../../api/client.js' import UserEditor from './UserEditor.jsx' +const ROLE_BADGE = { admin: 'badge-admin', editor: 'badge-editor', moderator: 'badge-moderator' } + export default function UsersAdmin() { const [tick, setTick] = useState(0) const reload = useCallback(() => setTick((t) => t + 1), []) @@ -16,7 +18,7 @@ export default function UsersAdmin() { - Manage admin and editor accounts + Manage admin, editor, and moderator accounts setEditing('new')} className="btn btn-primary btn-sq"> + Add user @@ -44,7 +46,7 @@ export default function UsersAdmin() { {u.username} - {u.role} + {u.role} {u.last_login_at ? dateTime(u.last_login_at) : 'never'} diff --git a/client/src/styles/theme.css b/client/src/styles/theme.css index 1b02e03..9da3ba0 100644 --- a/client/src/styles/theme.css +++ b/client/src/styles/theme.css @@ -613,6 +613,29 @@ button[disabled] { color: var(--muted); 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 { color: var(--accent); text-decoration: none; diff --git a/server/db/schema.sql b/server/db/schema.sql index 06fdd77..6323521 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -6,7 +6,7 @@ CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(32) NOT NULL UNIQUE, 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_enabled TINYINT(1) NOT NULL DEFAULT 0, -- 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) ) 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 -- 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. @@ -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; -- Session-revocation cutoff for databases created before token revocation landed. 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 category_id INT NULL; diff --git a/server/src/model/modNotes/modNotes.db.js b/server/src/model/modNotes/modNotes.db.js new file mode 100644 index 0000000..f36fec0 --- /dev/null +++ b/server/src/model/modNotes/modNotes.db.js @@ -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 } diff --git a/server/src/model/modNotes/modNotes.model.js b/server/src/model/modNotes/modNotes.model.js new file mode 100644 index 0000000..bada41b --- /dev/null +++ b/server/src/model/modNotes/modNotes.model.js @@ -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 } diff --git a/server/src/model/moderation/moderation.db.js b/server/src/model/moderation/moderation.db.js new file mode 100644 index 0000000..4f2b195 --- /dev/null +++ b/server/src/model/moderation/moderation.db.js @@ -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=), 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, +} diff --git a/server/src/model/moderation/moderation.model.js b/server/src/model/moderation/moderation.model.js new file mode 100644 index 0000000..5d775de --- /dev/null +++ b/server/src/model/moderation/moderation.model.js @@ -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 } diff --git a/server/src/model/moderation/moderation.pure.js b/server/src/model/moderation/moderation.pure.js new file mode 100644 index 0000000..b94ee85 --- /dev/null +++ b/server/src/model/moderation/moderation.pure.js @@ -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 } diff --git a/server/src/router/v1/admin/admin.routes.js b/server/src/router/v1/admin/admin.routes.js index c279c62..51f81c7 100644 --- a/server/src/router/v1/admin/admin.routes.js +++ b/server/src/router/v1/admin/admin.routes.js @@ -10,6 +10,7 @@ const account = require('./account.controller') const botActivity = require('./botActivity.controller') const authProviders = require('./authProviders.controller') const discordBot = require('./discordBot.controller') +const moderation = require('./moderation.controller') const { isLoggedIn, requireRole } = require('../../../utils/auth') const noindex = require('../../../middleware/noindex') const validate = require('../../../middleware/validate') @@ -23,6 +24,11 @@ adminRouter.use(noindex, isLoggedIn) // management, site mode, and settings are restricted to the admin role. 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) ─────────────── // Not behind adminOnly: an editor manages their own 2FA too. adminRouter.get( @@ -647,6 +653,70 @@ adminRouter.delete( 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) ────────────────────────────────────── adminRouter.use('/users', adminOnly) adminRouter.get( @@ -672,7 +742,7 @@ adminRouter.post( /* #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('password').isString().isLength({ min: 8, max: 64 }), - body('role').optional().isIn(['admin', 'editor']), + body('role').optional().isIn(['admin', 'editor', 'moderator']), validate, ctrl.createUser, ) @@ -692,7 +762,7 @@ adminRouter.put( param('id').isInt(), body('username').optional().isString().trim().isLength({ min: 3, max: 32 }), body('password').optional().isString().isLength({ min: 8, max: 64 }), - body('role').optional().isIn(['admin', 'editor']), + body('role').optional().isIn(['admin', 'editor', 'moderator']), validate, ctrl.updateUser, ) diff --git a/server/src/router/v1/admin/moderation.controller.js b/server/src/router/v1/admin/moderation.controller.js new file mode 100644 index 0000000..039a22f --- /dev/null +++ b/server/src/router/v1/admin/moderation.controller.js @@ -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, +} diff --git a/server/swagger/swagger-output.json b/server/swagger/swagger-output.json index 24af2c2..327c685 100644 --- a/server/swagger/swagger-output.json +++ b/server/swagger/swagger-output.json @@ -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": { "get": { "tags": [ diff --git a/server/test/moderation.test.js b/server/test/moderation.test.js new file mode 100644 index 0000000..627f7f6 --- /dev/null +++ b/server/test/moderation.test.js @@ -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) +})
+ Joins / leaves, filter hits, spam hits, and invite usage aren’t tracked yet — they arrive + when bot event capture lands (Phase 6b). +
+ No moderated users match “{term}”. +
{err}
- Manage admin and editor accounts + Manage admin, editor, and moderator accounts