Moderation dashboard: staff dashboard, user history, notes, event capture (Phase 6a + 6b) #42

Merged
whitlocktech merged 3 commits from feature/moderation-dashboard into main 2026-07-05 20:51:40 +00:00
28 changed files with 2121 additions and 23 deletions

View File

@@ -9,6 +9,8 @@ const messageFilter = require('./messageFilter')
const scheduler = require('../scheduler/scheduler') const scheduler = require('../scheduler/scheduler')
const roleMenuHandler = require('./roleMenuHandler') const roleMenuHandler = require('./roleMenuHandler')
const { handleGuildMemberAdd } = require('./guildMemberAdd') const { handleGuildMemberAdd } = require('./guildMemberAdd')
const { handleGuildMemberRemove } = require('./guildMemberRemove')
const inviteTracker = require('./inviteTracker')
const tempRoleSweeper = require('../roles/tempRoleSweeper') const tempRoleSweeper = require('../roles/tempRoleSweeper')
const inviteScheduler = require('../invites/inviteScheduler') const inviteScheduler = require('../invites/inviteScheduler')
@@ -58,13 +60,15 @@ async function start({ token, guildId: gid }) {
// GuildMessages + MessageContent (Phase 3, filter) and GuildMembers // GuildMessages + MessageContent (Phase 3, filter) and GuildMembers
// (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled // (Phase 5, auto-role + bulk role ops) are all privileged — must be enabled
// in the Discord Developer Portal, see the Phase 1 setup notes. // in the Discord Developer Portal, see the Phase 1 setup notes. GuildInvites
// (Phase 6b, invite-usage attribution) is NOT privileged — no portal toggle.
client = new Client({ client = new Client({
intents: [ intents: [
GatewayIntentBits.Guilds, GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent, GatewayIntentBits.MessageContent,
GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildInvites,
], ],
}) })
@@ -74,6 +78,7 @@ async function start({ token, guildId: gid }) {
await scheduler.start(client) await scheduler.start(client)
tempRoleSweeper.start(client) tempRoleSweeper.start(client)
inviteScheduler.start(client, guildId) inviteScheduler.start(client, guildId)
await inviteTracker.prime(client, guildId)
status = 'connected' status = 'connected'
statusDetail = null statusDetail = null
lastConnectedAt = new Date() lastConnectedAt = new Date()
@@ -102,6 +107,10 @@ async function start({ token, guildId: gid }) {
client.on('messageCreate', messageFilter.handleMessageCreate) client.on('messageCreate', messageFilter.handleMessageCreate)
client.on('guildMemberAdd', handleGuildMemberAdd) client.on('guildMemberAdd', handleGuildMemberAdd)
client.on('guildMemberRemove', handleGuildMemberRemove)
// Keep the invite-use cache fresh so guildMemberAdd can attribute joins.
client.on('inviteCreate', inviteTracker.onInviteCreate)
client.on('inviteDelete', inviteTracker.onInviteDelete)
client.on('error', (err) => { client.on('error', (err) => {
status = 'error' status = 'error'

View File

@@ -1,11 +1,37 @@
// Auto-role on join. Requires the Server Members privileged intent (already // Member join handling: record the join event (with best-effort invite
// enabled in the Discord Developer Portal per the Phase 1 setup notes). // attribution, Phase 6b) then apply the configured auto-role. Requires the
// Server Members privileged intent (already enabled per the Phase 1 setup notes)
// and, for invite attribution, the GuildInvites intent.
const guildConfig = require('../model/guildConfig') const guildConfig = require('../model/guildConfig')
const memberEvents = require('../model/memberEvents')
const inviteTracker = require('./inviteTracker')
const createLogger = require('../utils/logger') const createLogger = require('../utils/logger')
const log = createLogger('autorole') const log = createLogger('members')
async function handleGuildMemberAdd(member) { async function handleGuildMemberAdd(member) {
// Attribute the invite first (diffs the invite-use cache), then record the join.
// Both are best-effort — a failure here must never block the auto-role below.
let invite = { code: null, inviterId: null, inviterTag: null }
try {
invite = await inviteTracker.attribute(member)
} catch (err) {
log.warn('invite attribution threw', { userId: member.id, message: err.message })
}
try {
await memberEvents.record({
guildId: member.guild.id,
eventType: 'join',
discordUserId: member.id,
username: member.user?.tag,
inviteCode: invite.code,
inviterId: invite.inviterId,
inviterTag: invite.inviterTag,
})
} catch (err) {
log.warn('member join record failed', { userId: member.id, message: err.message })
}
try { try {
const roleId = await guildConfig.getAutoRoleId(member.guild.id) const roleId = await guildConfig.getAutoRoleId(member.guild.id)
if (!roleId) return if (!roleId) return

View File

@@ -0,0 +1,23 @@
// Member leave handling (Phase 6b): record a leave event for the dashboard's
// members feed. Fires on both voluntary leaves and kicks/bans — Discord doesn't
// distinguish them on this event, and the mod-action (if any) is logged
// separately via mod_actions, so a leave row here is purely the lifecycle fact.
const memberEvents = require('../model/memberEvents')
const createLogger = require('../utils/logger')
const log = createLogger('members')
async function handleGuildMemberRemove(member) {
try {
await memberEvents.record({
guildId: member.guild.id,
eventType: 'leave',
discordUserId: member.id,
username: member.user?.tag,
})
} catch (err) {
log.warn('member leave record failed', { userId: member.id, message: err.message })
}
}
module.exports = { handleGuildMemberRemove }

View File

@@ -0,0 +1,74 @@
// Best-effort invite-usage attribution (Phase 6b). Discord doesn't tell you
// which invite a member used, so the standard approach is to keep a cache of
// each invite's use-count and, on guildMemberAdd, re-fetch and find the one
// whose count went up. Requires the GuildInvites intent + Manage Guild (the bot
// already creates/deletes invites, so it has the permission). All calls are
// best-effort: any failure just yields a null attribution and the join is still
// recorded. Vanity-URL and bot-added joins are inherently unattributable.
const createLogger = require('../utils/logger')
const log = createLogger('invites')
// guildId -> Map<inviteCode, uses>
const cache = new Map()
async function snapshot(guild) {
const map = new Map()
const invites = await guild.invites.fetch()
for (const inv of invites.values()) map.set(inv.code, inv.uses || 0)
return map
}
// Populate the cache for a guild (call once the client is ready).
async function prime(client, guildId) {
try {
const guild = client.guilds.cache.get(guildId) || (await client.guilds.fetch(guildId))
cache.set(guildId, await snapshot(guild))
log.info('invite cache primed', { guildId, count: cache.get(guildId).size })
} catch (err) {
log.warn('invite cache prime failed (missing Manage Guild / GuildInvites?)', { message: err.message })
}
}
function onInviteCreate(invite) {
if (!invite.guild) return
const g = cache.get(invite.guild.id) || new Map()
g.set(invite.code, invite.uses || 0)
cache.set(invite.guild.id, g)
}
function onInviteDelete(invite) {
if (!invite.guild) return
const g = cache.get(invite.guild.id)
if (g) g.delete(invite.code)
}
// Diff current invite uses against the cached snapshot to find which invite the
// joining member used, then refresh the cache. Returns { code, inviterId,
// inviterTag } with nulls when it can't be determined.
async function attribute(member) {
const empty = { code: null, inviterId: null, inviterTag: null }
try {
const guild = member.guild
const before = cache.get(guild.id) || new Map()
const current = await guild.invites.fetch()
let found = empty
for (const inv of current.values()) {
const prev = before.get(inv.code) || 0
if ((inv.uses || 0) > prev && found === empty) {
found = { code: inv.code, inviterId: inv.inviter?.id || null, inviterTag: inv.inviter?.tag || null }
}
}
const next = new Map()
for (const inv of current.values()) next.set(inv.code, inv.uses || 0)
cache.set(guild.id, next)
return found
} catch (err) {
log.warn('invite attribution failed', { message: err.message })
return empty
}
}
module.exports = { prime, onInviteCreate, onInviteDelete, attribute }

View File

@@ -8,6 +8,8 @@ const { findMatch } = require('../filter/normalize')
const inviteFilter = require('../filter/inviteFilter') const inviteFilter = require('../filter/inviteFilter')
const spamFilter = require('../filter/spamFilter') const spamFilter = require('../filter/spamFilter')
const warnings = require('../model/warnings') const warnings = require('../model/warnings')
const filterHits = require('../model/filterHits')
const spamHits = require('../model/spamHits')
const modLog = require('./modLog') const modLog = require('./modLog')
const createLogger = require('../utils/logger') const createLogger = require('../utils/logger')
@@ -19,6 +21,48 @@ function botActor(client) {
return { id: client.user.id, tag: client.user.tag } return { id: client.user.id, tag: client.user.tag }
} }
// Dashboard event capture (Phase 6b). Best-effort — recording a hit must never
// break the moderation action it accompanies, so failures are swallowed+logged.
async function recordFilterHit(message, hitType, matched, actionTaken) {
try {
await filterHits.record({
guildId: message.guildId,
hitType,
discordUserId: message.author.id,
username: message.author.tag,
channelId: message.channelId,
matched,
actionTaken,
})
} catch (err) {
log.warn('filter hit record failed', { message: err.message })
}
}
async function recordSpamHit(message, spamType) {
try {
await spamHits.record({
guildId: message.guildId,
spamType,
discordUserId: message.author.id,
username: message.author.tag,
channelId: message.channelId,
})
} catch (err) {
log.warn('spam hit record failed', { message: err.message })
}
}
// Which spam rule tripped (for the spam_hits row). isRateLimited has a side
// effect (records this message's timestamp) so it must be evaluated first, and
// exactly once — mirroring the original OR-order.
function detectSpam(message) {
if (spamFilter.isRateLimited(message.guildId, message.author.id)) return 'rate_limit'
if (spamFilter.isMassMention(message)) return 'mass_mention'
if (spamFilter.isMassEmoji(message.content)) return 'mass_emoji'
return null
}
async function isBypassed(message, cache) { async function isBypassed(message, cache) {
if (cache.allowChannels.has(message.channelId)) return true if (cache.allowChannels.has(message.channelId)) return true
const memberRoles = message.member ? message.member.roles.cache : null const memberRoles = message.member ? message.member.roles.cache : null
@@ -62,8 +106,10 @@ async function handleMessageCreate(message) {
const cache = await filterCache.getOrLoad(message.guildId) const cache = await filterCache.getOrLoad(message.guildId)
if (await isBypassed(message, cache)) return if (await isBypassed(message, cache)) return
if (await inviteFilter.containsForeignInvite(message)) { const foreignCode = await inviteFilter.foreignInviteCode(message)
if (foreignCode) {
await message.delete().catch(() => {}) await message.delete().catch(() => {})
await recordFilterHit(message, 'invite', foreignCode, 'warn')
await applyWarnAction(message, 'Posted a Discord invite link') await applyWarnAction(message, 'Posted a Discord invite link')
return return
} }
@@ -71,17 +117,16 @@ async function handleMessageCreate(message) {
const match = findMatch(message.content, cache.words) const match = findMatch(message.content, cache.words)
if (match) { if (match) {
await message.delete().catch(() => {}) await message.delete().catch(() => {})
await recordFilterHit(message, 'word', match.word, match.severity)
if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`) if (match.severity === 'mute') await applyMuteAction(message, `Filtered word: ${match.word}`)
else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`) else if (match.severity === 'warn') await applyWarnAction(message, `Filtered word: ${match.word}`)
return return
} }
if ( const spamType = detectSpam(message)
spamFilter.isRateLimited(message.guildId, message.author.id) || if (spamType) {
spamFilter.isMassMention(message) ||
spamFilter.isMassEmoji(message.content)
) {
await message.delete().catch(() => {}) await message.delete().catch(() => {})
await recordSpamHit(message, spamType)
await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)') await applyWarnAction(message, 'Automated spam detection (rate limit / mass mention / mass emoji)')
} }
} catch (err) { } catch (err) {

View File

@@ -4,20 +4,23 @@
// than silently letting an unresolvable link through. // than silently letting an unresolvable link through.
const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi const INVITE_REGEX = /(?:discord\.gg|discord(?:app)?\.com\/invite)\/([a-zA-Z0-9-]+)/gi
async function containsForeignInvite(message) { // Returns the first foreign (or unresolvable) invite code found in the message,
// or null if the message contains no foreign invites. Returning the code (rather
// than a bare boolean) lets the caller record which invite was blocked.
async function foreignInviteCode(message) {
const matches = [...message.content.matchAll(INVITE_REGEX)] const matches = [...message.content.matchAll(INVITE_REGEX)]
if (matches.length === 0) return false if (matches.length === 0) return null
for (const match of matches) { for (const match of matches) {
const code = match[1] const code = match[1]
try { try {
const invite = await message.client.fetchInvite(code) const invite = await message.client.fetchInvite(code)
if (invite.guild?.id !== message.guildId) return true if (invite.guild?.id !== message.guildId) return code
} catch { } catch {
return true return code
} }
} }
return false return null
} }
module.exports = { containsForeignInvite } module.exports = { foreignInviteCode }

View File

@@ -0,0 +1,15 @@
// Automated content-filter hits (Phase 6b). Bot-owned; recorded whenever the
// word filter or foreign-invite filter deletes a message. mod_actions still
// records the resulting warn/mute separately. Schema: server/db/schema.sql
// (filter_hits).
const db = require('../db')
async function record({ guildId, hitType, discordUserId, username, channelId, matched, actionTaken }) {
await db.query(
`INSERT INTO filter_hits (guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[guildId, hitType, discordUserId, username || null, channelId || null, matched || null, actionTaken],
)
}
module.exports = { record }

View File

@@ -0,0 +1,14 @@
// Guild member join/leave events (Phase 6b). Bot-owned; the site reads these for
// the moderation dashboard's members feed + invite-usage view. Schema in
// server/db/schema.sql (member_events).
const db = require('../db')
async function record({ guildId, eventType, discordUserId, username, inviteCode, inviterId, inviterTag }) {
await db.query(
`INSERT INTO member_events (guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[guildId, eventType, discordUserId, username || null, inviteCode || null, inviterId || null, inviterTag || null],
)
}
module.exports = { record }

14
bot/src/model/spamHits.js Normal file
View File

@@ -0,0 +1,14 @@
// Automated spam-detection hits (Phase 6b). Bot-owned; recorded when the
// rate-limit / mass-mention / mass-emoji checks trip. mod_actions still logs the
// resulting warn separately. Schema: server/db/schema.sql (spam_hits).
const db = require('../db')
async function record({ guildId, spamType, discordUserId, username, channelId }) {
await db.query(
`INSERT INTO spam_hits (guild_id, spam_type, discord_user_id, username, channel_id)
VALUES (?, ?, ?, ?, ?)`,
[guildId, spamType, discordUserId, username || null, channelId || null],
)
}
module.exports = { record }

View File

@@ -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 />} />

View File

@@ -120,6 +120,52 @@ 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)}`),
modMembers: (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/members${s ? `?${s}` : ''}`)
},
modFilterHits: (params = {}) => {
const qs = new URLSearchParams()
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/filter-hits${s ? `?${s}` : ''}`)
},
modSpamHits: (params = {}) => {
const qs = new URLSearchParams()
if (params.limit) qs.set('limit', params.limit)
if (params.offset) qs.set('offset', params.offset)
const s = qs.toString()
return req(`/admin/moderation/spam-hits${s ? `?${s}` : ''}`)
},
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' }),

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

View File

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

View File

@@ -0,0 +1,330 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Loading, ErrorState } from '../../../components/PageState.jsx'
import { useAsync } from '../../../lib/useAsync.js'
import { ago, dateTime } from '../../../lib/format.js'
import { api } from '../../../api/client.js'
const WINDOWS = [
{ key: '24h', label: 'Last 24h' },
{ key: '7d', label: 'Last 7 days' },
{ key: '30d', label: 'Last 30 days' },
]
const TYPES = [
{ key: null, label: 'All' },
{ key: 'ban', label: 'Bans' },
{ key: 'kick', label: 'Kicks' },
{ key: 'mute', label: 'Mutes' },
{ key: 'warn', label: 'Warnings' },
]
const MOD_TILES = [
{ key: 'ban', label: 'Bans' },
{ key: 'kick', label: 'Kicks' },
{ key: 'mute', label: 'Mutes' },
{ key: 'warn', label: 'Warnings' },
]
// Second tile row → jumps the events panel to the matching stream.
const EVENT_TILES = [
{ key: 'joins', label: 'Joins', tab: 'members' },
{ key: 'leaves', label: 'Leaves', tab: 'members' },
{ key: 'filter_hits', label: 'Filter hits', tab: 'filter' },
{ key: 'spam_hits', label: 'Spam hits', tab: 'spam' },
]
const EVENT_TABS = [
{ key: 'members', label: 'Members' },
{ key: 'filter', label: 'Filter hits' },
{ key: 'spam', label: 'Spam hits' },
]
export default function Moderation() {
const navigate = useNavigate()
const [win, setWin] = useState('24h')
const [typeFilter, setTypeFilter] = useState(null)
const [eventTab, setEventTab] = useState('members')
const { loading, error, data } = useAsync(
() =>
Promise.all([
api.admin.modSummary(),
api.admin.modRecent({ limit: 100 }),
api.admin.modMembers({ limit: 50 }),
api.admin.modFilterHits({ limit: 50 }),
api.admin.modSpamHits({ limit: 50 }),
]),
[],
)
if (loading) return <Loading />
if (error) return <ErrorState message="Could not load moderation data." />
const [summary, recent, members, filterHits, spamHits] = data
const counts = summary.windows?.[win] || {}
const feed = typeFilter ? recent.filter((r) => r.action_type === typeFilter) : recent
const goUser = (id) => navigate(`/admin/moderation/user/${id}`)
return (
<section>
<UserSearch onPick={goUser} />
{/* Window selector */}
<div style={{ display: 'flex', gap: 8, margin: '4px 0 14px' }}>
{WINDOWS.map((w) => (
<button key={w.key} onClick={() => setWin(w.key)} className="pill" style={win === w.key ? activePill : undefined}>
{w.label}
</button>
))}
</div>
{/* Moderation-action tiles (click filters the recent-actions feed) */}
<div className="grid-4" style={{ gap: 14, marginBottom: 14 }}>
{MOD_TILES.map((t) => (
<Tile
key={t.key}
value={counts[t.key] ?? 0}
label={t.label}
active={typeFilter === t.key}
onClick={() => setTypeFilter(typeFilter === t.key ? null : t.key)}
/>
))}
</div>
{/* Event tiles (click jumps the events panel to that stream) */}
<div className="grid-4" style={{ gap: 14, marginBottom: 8 }}>
{EVENT_TILES.map((t) => (
<Tile
key={t.key}
value={counts[t.key] ?? 0}
label={t.label}
sub={t.key === 'joins' && counts.invite_joins ? `${counts.invite_joins} via invite` : null}
active={eventTab === t.tab}
onClick={() => setEventTab(t.tab)}
/>
))}
</div>
<p className="sans dim" style={{ fontSize: '0.78rem', margin: '0 0 24px' }}>
Counts are for the selected window. Member, filter, and spam events are captured live by the bot.
</p>
{/* Recent moderation actions */}
<div style={rowHead}>
<h2 className="display" style={h2}>Recent actions</h2>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{TYPES.map((t) => (
<button key={t.label} onClick={() => setTypeFilter(t.key)} className="pill" style={typeFilter === t.key ? activePill : undefined}>
{t.label}
</button>
))}
</div>
</div>
<div className="panel-flat" style={{ marginBottom: 30 }}>
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Action</th>
<th className="adm-th">Target</th>
<th className="adm-th">Staff</th>
<th className="adm-th">Reason</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{feed.length === 0 && (
<tr><td className="adm-td" colSpan={5} style={muted}>No matching actions.</td></tr>
)}
{feed.map((a) => (
<tr key={a.id}>
<td className="adm-td"><span className={`badge badge-${a.action_type}`}>{a.action_type}</span></td>
<td className="adm-td">
<span className="link-accent" onClick={() => goUser(a.target_user_id)}>{a.target_tag || a.target_user_id}</span>
{a.linked_account && <span className="badge badge-editor" style={{ marginLeft: 8 }}>site: {a.linked_account.username}</span>}
</td>
<td className="adm-td">
{a.is_automated ? <span className="badge badge-auto">Automated</span> : <span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</span>}
</td>
<td className="adm-td" style={{ color: 'var(--muted)', maxWidth: 280 }}>{a.reason || '—'}</td>
<td className="adm-td dim" title={dateTime(a.created_at)}>{ago(a.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Event streams panel */}
<div style={rowHead}>
<h2 className="display" style={h2}>Events</h2>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{EVENT_TABS.map((t) => (
<button key={t.key} onClick={() => setEventTab(t.key)} className="pill" style={eventTab === t.key ? activePill : undefined}>
{t.label}
</button>
))}
</div>
</div>
{eventTab === 'members' && <MembersTable rows={members} onUser={goUser} />}
{eventTab === 'filter' && <FilterTable rows={filterHits} onUser={goUser} />}
{eventTab === 'spam' && <SpamTable rows={spamHits} onUser={goUser} />}
</section>
)
}
function Tile({ value, label, sub, active, onClick }) {
return (
<button
onClick={onClick}
style={{
textAlign: 'left',
padding: 20,
border: `1px solid ${active ? 'var(--accent)' : 'var(--line)'}`,
borderRadius: 12,
background: 'var(--panel-grad)',
cursor: 'pointer',
}}
>
<div className="display" style={{ fontSize: '2rem', color: 'var(--head)', lineHeight: 1 }}>{value}</div>
<div className="card-kicker" style={{ marginTop: 8, marginBottom: 0 }}>{label}</div>
{sub && <div className="sans dim" style={{ fontSize: '0.68rem', marginTop: 4 }}>{sub}</div>}
</button>
)
}
function MembersTable({ rows, onUser }) {
return (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Event</th>
<th className="adm-th">User</th>
<th className="adm-th">Invite</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && <tr><td className="adm-td" colSpan={4} style={muted}>No member events yet.</td></tr>}
{rows.map((m) => (
<tr key={m.id}>
<td className="adm-td"><span className={`badge ${m.event_type === 'join' ? 'badge-pub' : 'badge-ban'}`}>{m.event_type}</span></td>
<td className="adm-td"><span className="link-accent" onClick={() => onUser(m.discord_user_id)}>{m.username || m.discord_user_id}</span></td>
<td className="adm-td dim">
{m.invite_code ? (
<span>{m.invite_code}{m.inviter_tag ? ` · by ${m.inviter_tag}` : ''}</span>
) : '—'}
</td>
<td className="adm-td dim" title={dateTime(m.created_at)}>{ago(m.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function FilterTable({ rows, onUser }) {
return (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Type</th>
<th className="adm-th">User</th>
<th className="adm-th">Matched</th>
<th className="adm-th">Action</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && <tr><td className="adm-td" colSpan={5} style={muted}>No filter hits yet.</td></tr>}
{rows.map((f) => (
<tr key={f.id}>
<td className="adm-td"><span className={`badge ${f.hit_type === 'invite' ? 'badge-ban' : 'badge-warn'}`}>{f.hit_type}</span></td>
<td className="adm-td"><span className="link-accent" onClick={() => onUser(f.discord_user_id)}>{f.username || f.discord_user_id}</span></td>
<td className="adm-td" style={{ color: 'var(--text)', maxWidth: 240 }}>{f.matched || '—'}</td>
<td className="adm-td"><span className={`badge badge-${f.action_taken === 'delete' ? 'auto' : f.action_taken}`}>{f.action_taken}</span></td>
<td className="adm-td dim" title={dateTime(f.created_at)}>{ago(f.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
const SPAM_LABEL = { rate_limit: 'Rate limit', mass_mention: 'Mass mention', mass_emoji: 'Mass emoji' }
function SpamTable({ rows, onUser }) {
return (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Type</th>
<th className="adm-th">User</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && <tr><td className="adm-td" colSpan={3} style={muted}>No spam hits yet.</td></tr>}
{rows.map((s) => (
<tr key={s.id}>
<td className="adm-td"><span className="badge badge-warn">{SPAM_LABEL[s.spam_type] || s.spam_type}</span></td>
<td className="adm-td"><span className="link-accent" onClick={() => onUser(s.discord_user_id)}>{s.username || s.discord_user_id}</span></td>
<td className="adm-td dim" title={dateTime(s.created_at)}>{ago(s.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// User lookup: search by Discord id or a historical username snapshot.
function UserSearch({ onPick }) {
const [term, setTerm] = useState('')
const [results, setResults] = useState(null)
const [busy, setBusy] = useState(false)
async function run(e) {
e.preventDefault()
const q = term.trim()
if (!q) return
setBusy(true)
try {
setResults(await api.admin.modSearch(q))
} finally {
setBusy(false)
}
}
return (
<div style={{ marginBottom: 22 }}>
<form onSubmit={run} style={{ display: 'flex', gap: 8 }}>
<input className="input" placeholder="Search by Discord ID or username…" value={term} onChange={(e) => setTerm(e.target.value)} style={{ maxWidth: 360 }} />
<button type="submit" className="btn btn-primary btn-sq" disabled={busy}>{busy ? 'Searching…' : 'Look up'}</button>
</form>
{results && results.length === 0 && (
<p className="sans dim" style={{ fontSize: '0.82rem', marginTop: 10 }}>No moderated users match {term}.</p>
)}
{results && results.length > 0 && (
<div className="panel-flat" style={{ marginTop: 10 }}>
<table className="adm-table">
<tbody>
{results.map((r) => (
<tr key={r.target_user_id} style={{ cursor: 'pointer' }} onClick={() => onPick(r.target_user_id)}>
<td className="adm-td" style={{ color: 'var(--head)' }}>{r.target_tag || '(unknown tag)'}</td>
<td className="adm-td dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem' }}>{r.target_user_id}</td>
<td className="adm-td dim">{r.action_count} action{Number(r.action_count) === 1 ? '' : 's'}</td>
<td className="adm-td dim">last {ago(r.last_seen)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
const activePill = { background: 'var(--blue)', color: 'var(--ink)', borderColor: 'var(--accent)' }
const rowHead = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, flexWrap: 'wrap', marginBottom: 12 }
const h2 = { margin: 0, fontSize: '1.25rem', color: 'var(--head)' }
const muted = { color: 'var(--muted)' }

View 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 users history." />
const [summary, actions, notes] = data
const counts = summary.counts || {}
const tabActions = actions.filter((a) => a.action_type === tab)
return (
<section>
<Link to="/admin/moderation" className="link-accent" style={{ fontSize: '0.85rem' }}>
Back to moderation
</Link>
{/* Header */}
<div style={{ padding: 22, border: '1px solid var(--line)', borderRadius: 12, background: 'var(--panel-grad)', margin: '12px 0 20px' }}>
<div style={{ display: 'flex', alignItems: 'baseline', gap: 12, flexWrap: 'wrap' }}>
<span className="display" style={{ fontSize: '1.5rem', color: 'var(--head)' }}>
{summary.tag || '(unknown user)'}
</span>
{summary.linked_account && (
<span className="badge badge-editor">site account: {summary.linked_account.username}</span>
)}
</div>
<div className="sans dim" style={{ fontFamily: 'ui-monospace,Menlo,monospace', fontSize: '0.8rem', marginTop: 4 }}>
{discordId}
</div>
<div style={{ display: 'flex', gap: 18, marginTop: 14, flexWrap: 'wrap' }}>
{ACTION_TABS.map((t) => (
<Count key={t.key} label={t.label} value={counts[t.key] || 0} />
))}
<Count label="Notes" value={summary.notes_count || 0} />
</div>
</div>
{/* Tabs */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 14, borderBottom: '1px solid var(--line-soft)', paddingBottom: 12 }}>
{ACTION_TABS.map((t) => (
<TabButton key={t.key} active={tab === t.key} onClick={() => setTab(t.key)}>
{t.label} ({counts[t.key] || 0})
</TabButton>
))}
<TabButton active={tab === 'notes'} onClick={() => setTab('notes')}>
Notes ({summary.notes_count || 0})
</TabButton>
</div>
{tab === 'notes' ? (
<NotesTab discordId={discordId} notes={notes} isAdmin={isAdmin} onAdded={reload} />
) : (
<ActionTable rows={tabActions} showDuration={tab === 'mute'} />
)}
</section>
)
}
function Count({ label, value }) {
return (
<div>
<div className="display" style={{ fontSize: '1.4rem', color: 'var(--head)', lineHeight: 1 }}>{value}</div>
<div className="card-kicker" style={{ marginTop: 4, marginBottom: 0 }}>{label}</div>
</div>
)
}
function TabButton({ active, onClick, children }) {
return (
<button
onClick={onClick}
className="sans"
style={{
border: '1px solid var(--line)',
borderRadius: 8,
padding: '7px 14px',
cursor: 'pointer',
fontSize: '0.85rem',
background: active ? 'var(--blue)' : 'transparent',
color: active ? 'var(--ink)' : 'var(--muted)',
borderColor: active ? 'var(--accent)' : 'var(--line)',
}}
>
{children}
</button>
)
}
function ActionTable({ rows, showDuration }) {
return (
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Reason</th>
<th className="adm-th">Actor</th>
{showDuration && <th className="adm-th">Duration</th>}
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{rows.length === 0 && (
<tr>
<td className="adm-td" colSpan={showDuration ? 4 : 3} style={{ color: 'var(--muted)' }}>
Nothing here.
</td>
</tr>
)}
{rows.map((a) => (
<tr key={a.id}>
<td className="adm-td" style={{ color: 'var(--text)' }}>{a.reason || '—'}</td>
<td className="adm-td">
{a.is_automated ? (
<span className="badge badge-auto">Automated</span>
) : (
<span style={{ color: 'var(--text)' }}>{a.staff_tag || a.staff_user_id}</span>
)}
</td>
{showDuration && <td className="adm-td dim">{fmtDuration(a.duration_seconds) || '—'}</td>}
<td className="adm-td dim" title={dateTime(a.created_at)}>{dateTime(a.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function NotesTab({ discordId, notes, isAdmin, onAdded }) {
const [body, setBody] = useState('')
const [visibility, setVisibility] = useState('staff_only')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
async function add() {
if (!body.trim()) return
setBusy(true)
setErr('')
try {
await api.admin.addModNote(discordId, { body: body.trim(), visibility })
setBody('')
setVisibility('staff_only')
onAdded()
} catch (e) {
setErr(e.message || 'Could not save the note.')
} finally {
setBusy(false)
}
}
return (
<div>
<div style={{ marginBottom: 18 }}>
{err && <p className="sans" style={{ margin: '0 0 8px', color: '#d98b84', fontSize: '0.85rem' }}>{err}</p>}
<textarea
className="textarea"
placeholder="Add a staff note about this user…"
value={body}
onChange={(e) => setBody(e.target.value)}
rows={3}
style={{ width: '100%' }}
/>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' }}>
<select value={visibility} onChange={(e) => setVisibility(e.target.value)} className="select" style={{ maxWidth: 200 }}>
<option value="staff_only">Staff only</option>
{isAdmin && <option value="admin_only">Admin only</option>}
</select>
<button onClick={add} disabled={busy || !body.trim()} className="btn btn-primary btn-sq">
{busy ? 'Saving…' : 'Add note'}
</button>
</div>
</div>
<div className="panel-flat">
<table className="adm-table">
<thead>
<tr>
<th className="adm-th">Note</th>
<th className="adm-th">Author</th>
<th className="adm-th">Visibility</th>
<th className="adm-th">When</th>
</tr>
</thead>
<tbody>
{notes.length === 0 && (
<tr>
<td className="adm-td" colSpan={4} style={{ color: 'var(--muted)' }}>No notes yet.</td>
</tr>
)}
{notes.map((n) => (
<tr key={n.id}>
<td className="adm-td" style={{ color: 'var(--text)', whiteSpace: 'pre-wrap' }}>{n.body}</td>
<td className="adm-td dim">{n.author_username || n.author_tag || '—'}</td>
<td className="adm-td">
<span className={`badge ${n.visibility === 'admin_only' ? 'badge-ban' : 'badge-editor'}`}>
{n.visibility === 'admin_only' ? 'admin only' : 'staff'}
</span>
</td>
<td className="adm-td dim" title={dateTime(n.created_at)}>{ago(n.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@@ -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>

View File

@@ -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' }}>

View File

@@ -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;

View File

@@ -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,83 @@ 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;
-- Guild member join/leave events (Phase 6b). Powers the dashboard's joins/leaves
-- feeds and the invite-usage view. Bot-owned (written by bot/src/discord/
-- guildMemberAdd.js + guildMemberRemove.js). For joins, invite_code/inviter_*
-- record which invite was used when the bot could attribute it (best-effort, see
-- bot/src/discord/inviteTracker.js) — NULL when undeterminable or for leaves.
-- These are member lifecycle events, not moderation actions, hence separate from
-- mod_actions.
CREATE TABLE IF NOT EXISTS member_events (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
event_type ENUM('join','leave') NOT NULL,
discord_user_id VARCHAR(32) NOT NULL,
username VARCHAR(120) NULL,
invite_code VARCHAR(20) NULL,
inviter_id VARCHAR(32) NULL,
inviter_tag VARCHAR(120) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_member_events_guild (guild_id, created_at),
INDEX idx_member_events_user (guild_id, discord_user_id, created_at),
INDEX idx_member_events_invite (guild_id, invite_code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Automated content-filter hits (Phase 6b): one row per message the word filter
-- or the foreign-invite filter deleted. Separate from mod_actions (which still
-- records the resulting warn/mute) so the dashboard can show filter volume in
-- its own right. `matched` holds the offending word (word hits) or the blocked
-- invite code (invite hits); `action_taken` is what the pipeline did. Bot-owned
-- (bot/src/discord/messageFilter.js).
CREATE TABLE IF NOT EXISTS filter_hits (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
hit_type ENUM('word','invite') NOT NULL,
discord_user_id VARCHAR(32) NOT NULL,
username VARCHAR(120) NULL,
channel_id VARCHAR(32) NULL,
matched VARCHAR(200) NULL,
action_taken ENUM('delete','warn','mute') NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_filter_hits_guild (guild_id, created_at),
INDEX idx_filter_hits_user (guild_id, discord_user_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Automated spam-detection hits (Phase 6b): rate-limit / mass-mention /
-- mass-emoji triggers. As with filter_hits, mod_actions still logs the resulting
-- warn; this records the detection itself for the dashboard's spam feed.
-- Bot-owned (bot/src/discord/messageFilter.js via bot/src/filter/spamFilter.js).
CREATE TABLE IF NOT EXISTS spam_hits (
id INT AUTO_INCREMENT PRIMARY KEY,
guild_id VARCHAR(32) NOT NULL,
spam_type ENUM('rate_limit','mass_mention','mass_emoji') NOT NULL,
discord_user_id VARCHAR(32) NOT NULL,
username VARCHAR(120) NULL,
channel_id VARCHAR(32) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_spam_hits_guild (guild_id, created_at),
INDEX idx_spam_hits_user (guild_id, discord_user_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 -- 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 +455,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;

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

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

View File

@@ -0,0 +1,188 @@
// 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
}
// ── Phase 6b: member events + filter/spam hits (bot-owned, read-only) ──
// Join/leave counts per window (grouped by event_type).
async function memberCountsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
return query(
`SELECT event_type,
SUM(created_at >= ?) AS d1,
SUM(created_at >= ?) AS d7,
SUM(created_at >= ?) AS d30
FROM member_events
WHERE created_at >= ?
GROUP BY event_type`,
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
)
}
// Attributed-invite join counts per window (joins whose invite we identified).
async function inviteJoinCountsByWindow({ cutoff24h, cutoff7d, cutoff30d }) {
const rows = await query(
`SELECT SUM(created_at >= ?) AS d1, SUM(created_at >= ?) AS d7, SUM(created_at >= ?) AS d30
FROM member_events
WHERE event_type = 'join' AND invite_code IS NOT NULL AND created_at >= ?`,
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
)
return rows[0]
}
// Row-count per window for a simple event table. `table` is an internal literal
// ('filter_hits' | 'spam_hits'), never user input — see the caller allowlist.
async function tableCountsByWindow(table, { cutoff24h, cutoff7d, cutoff30d }) {
const rows = await query(
`SELECT SUM(created_at >= ?) AS d1, SUM(created_at >= ?) AS d7, SUM(created_at >= ?) AS d30
FROM ${table} WHERE created_at >= ?`,
[cutoff24h, cutoff7d, cutoff30d, cutoff30d],
)
return rows[0]
}
async function recentMemberEvents({ type = null, limit = 50, offset = 0 } = {}) {
const where = type ? 'WHERE event_type = ?' : ''
const params = type ? [type, limit, offset] : [limit, offset]
return query(
`SELECT id, guild_id, event_type, discord_user_id, username, invite_code, inviter_id, inviter_tag, created_at
FROM member_events ${where} ORDER BY id DESC LIMIT ? OFFSET ?`,
params,
)
}
async function recentFilterHits({ limit = 50, offset = 0 } = {}) {
return query(
`SELECT id, guild_id, hit_type, discord_user_id, username, channel_id, matched, action_taken, created_at
FROM filter_hits ORDER BY id DESC LIMIT ? OFFSET ?`,
[limit, offset],
)
}
async function recentSpamHits({ limit = 50, offset = 0 } = {}) {
return query(
`SELECT id, guild_id, spam_type, discord_user_id, username, channel_id, created_at
FROM spam_hits ORDER BY id DESC LIMIT ? OFFSET ?`,
[limit, offset],
)
}
// 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,
// Phase 6b
memberCountsByWindow,
inviteJoinCountsByWindow,
tableCountsByWindow,
recentMemberEvents,
recentFilterHits,
recentSpamHits,
}

View File

@@ -0,0 +1,105 @@
// 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, windowValue } = require('./moderation.pure')
const DAY_MS = 24 * 60 * 60 * 1000
const WINDOW_KEYS = ['24h', '7d', '30d']
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. Covers
// moderation actions (mod_actions) plus the Phase 6b event streams: member
// joins/leaves, attributed invite joins, and filter/spam hits.
async function summary() {
const now = Date.now()
const cutoffs = {
cutoff24h: new Date(now - DAY_MS),
cutoff7d: new Date(now - 7 * DAY_MS),
cutoff30d: new Date(now - 30 * DAY_MS),
}
const [modRows, memberRows, inviteRow, filterRow, spamRow] = await Promise.all([
moderationDb.countsByWindow(cutoffs),
moderationDb.memberCountsByWindow(cutoffs),
moderationDb.inviteJoinCountsByWindow(cutoffs),
moderationDb.tableCountsByWindow('filter_hits', cutoffs),
moderationDb.tableCountsByWindow('spam_hits', cutoffs),
])
const windows = reshapeWindows(modRows).windows
const joinRow = memberRows.find((r) => r.event_type === 'join')
const leaveRow = memberRows.find((r) => r.event_type === 'leave')
for (const w of WINDOW_KEYS) {
windows[w].joins = windowValue(joinRow, w)
windows[w].leaves = windowValue(leaveRow, w)
windows[w].invite_joins = windowValue(inviteRow, w)
windows[w].filter_hits = windowValue(filterRow, w)
windows[w].spam_hits = windowValue(spamRow, w)
}
return { windows }
}
// Recent event feeds for the overview's secondary panel (Phase 6b).
async function members(opts) {
return moderationDb.recentMemberEvents(opts)
}
async function filterHits(opts) {
return moderationDb.recentFilterHits(opts)
}
async function spamHits(opts) {
return moderationDb.recentSpamHits(opts)
}
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, members, filterHits, spamHits }

View File

@@ -0,0 +1,47 @@
// 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 }
}
// Pull the count for one window key ('24h'|'7d'|'30d') out of a
// { d1, d7, d30 } sum row, coercing to a number and tolerating a null row.
function windowValue(row, key) {
if (!row) return 0
const col = key === '24h' ? row.d1 : key === '7d' ? row.d7 : row.d30
return Number(col) || 0
}
module.exports = { zeroCounts, annotate, reshapeWindows, windowValue }

View File

@@ -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,91 @@ 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/members',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Recent member join/leave events (optionally filtered by type)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
moderation.getMembers,
)
adminRouter.get(
'/moderation/filter-hits',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Recent automated content-filter hits'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
moderation.getFilterHits,
)
adminRouter.get(
'/moderation/spam-hits',
// #swagger.tags = ['Admin · Moderation']
// #swagger.summary = 'Recent automated spam-detection hits'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
moderation.getSpamHits,
)
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 +763,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 +783,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,
) )

View File

@@ -0,0 +1,170 @@
// 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' })
}
}
// ── Phase 6b event feeds ──────────────────────────────────────────────
const MEMBER_TYPES = new Set(['join', 'leave'])
async function getMembers(req, res) {
try {
const { limit, offset } = pageParams(req)
const t = MEMBER_TYPES.has(req.query.type) ? req.query.type : null
return res.json(await moderation.members({ type: t, limit, offset }))
} catch (err) {
log.error('members failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getFilterHits(req, res) {
try {
const { limit, offset } = pageParams(req)
return res.json(await moderation.filterHits({ limit, offset }))
} catch (err) {
log.error('filterHits failed', { error: err.message })
return res.status(500).json({ message: 'Internal Server Error' })
}
}
async function getSpamHits(req, res) {
try {
const { limit, offset } = pageParams(req)
return res.json(await moderation.spamHits({ limit, offset }))
} catch (err) {
log.error('spamHits 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,
getMembers,
getFilterHits,
getSpamHits,
getUser,
getUserActions,
getUserNotes,
addUserNote,
}

View File

@@ -3856,6 +3856,344 @@
] ]
} }
}, },
"/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/members": {
"get": {
"tags": [
"Admin · Moderation"
],
"summary": "Recent member join/leave events (optionally filtered by type)",
"description": "",
"parameters": [
{
"name": "type",
"in": "query",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/moderation/filter-hits": {
"get": {
"tags": [
"Admin · Moderation"
],
"summary": "Recent automated content-filter hits",
"description": "",
"responses": {
"200": {
"description": "OK"
},
"500": {
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
]
}
},
"/api/v1/admin/moderation/spam-hits": {
"get": {
"tags": [
"Admin · Moderation"
],
"summary": "Recent automated spam-detection hits",
"description": "",
"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": [

View File

@@ -0,0 +1,89 @@
// 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)
})
test('windowValue: picks the right column per window key and coerces to number', () => {
const row = { d1: '2', d7: 5, d30: '11' }
assert.strictEqual(moderation.windowValue(row, '24h'), 2)
assert.strictEqual(moderation.windowValue(row, '7d'), 5)
assert.strictEqual(moderation.windowValue(row, '30d'), 11)
})
test('windowValue: null row (no rows in window) yields 0', () => {
assert.strictEqual(moderation.windowValue(null, '24h'), 0)
assert.strictEqual(moderation.windowValue(undefined, '30d'), 0)
})
test('windowValue: null sum column yields 0', () => {
assert.strictEqual(moderation.windowValue({ d1: null, d7: null, d30: null }, '7d'), 0)
})