From b0c0d1fe9b60b1dc642a0798864fbcd565aa3d39 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 10:16:34 -0500 Subject: [PATCH 1/2] 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 Claude-Session: https://claude.ai/code/session_019rao86n5cXpwAyjdBFEshV --- client/src/App.jsx | 14 + client/src/api/client.js | 24 ++ client/src/components/RoleGate.jsx | 11 + client/src/routes/admin/AdminLayout.jsx | 29 +- client/src/routes/admin/views/Moderation.jsx | 234 ++++++++++++++++ .../src/routes/admin/views/ModerationUser.jsx | 245 +++++++++++++++++ client/src/routes/admin/views/UserEditor.jsx | 1 + client/src/routes/admin/views/UsersAdmin.jsx | 6 +- client/src/styles/theme.css | 23 ++ server/db/schema.sql | 25 +- server/src/model/modNotes/modNotes.db.js | 51 ++++ server/src/model/modNotes/modNotes.model.js | 18 ++ server/src/model/moderation/moderation.db.js | 117 ++++++++ .../src/model/moderation/moderation.model.js | 72 +++++ .../src/model/moderation/moderation.pure.js | 39 +++ server/src/router/v1/admin/admin.routes.js | 74 ++++- .../router/v1/admin/moderation.controller.js | 133 +++++++++ server/swagger/swagger-output.json | 254 ++++++++++++++++++ server/test/moderation.test.js | 73 +++++ 19 files changed, 1436 insertions(+), 7 deletions(-) create mode 100644 client/src/components/RoleGate.jsx create mode 100644 client/src/routes/admin/views/Moderation.jsx create mode 100644 client/src/routes/admin/views/ModerationUser.jsx create mode 100644 server/src/model/modNotes/modNotes.db.js create mode 100644 server/src/model/modNotes/modNotes.model.js create mode 100644 server/src/model/moderation/moderation.db.js create mode 100644 server/src/model/moderation/moderation.model.js create mode 100644 server/src/model/moderation/moderation.pure.js create mode 100644 server/src/router/v1/admin/moderation.controller.js create mode 100644 server/test/moderation.test.js 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() {