diff --git a/AGENTS.md b/AGENTS.md index 6bd02e0d5..58d5967fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,10 +35,14 @@ | `src/modules/events.js` | Event handler registration (wires modules to Discord events) | | `src/api/server.js` | Express API server setup (createApp, startServer, stopServer) | | `src/api/index.js` | API route mounting | +| `src/api/middleware/auditLog.js` | Audit logging middleware for authenticated mutating API requests | | `src/api/routes/guilds.js` | Guild REST API endpoints (info, channels, roles, config, stats, members, moderation, analytics, actions) | +| `src/api/routes/auditLog.js` | Audit log retrieval endpoint (filters + pagination) | | `web/src/components/dashboard/analytics-dashboard.tsx` | Analytics dashboard React component — charts, KPIs, date range controls | | `web/src/types/analytics.ts` | Shared analytics TypeScript contracts used by dashboard UI and analytics API responses | | `web/src/app/api/guilds/[guildId]/analytics/route.ts` | Next.js API route — proxies analytics requests to bot API with param allowlisting | +| `web/src/app/dashboard/audit-log/page.tsx` | Audit log dashboard page (filterable audit timeline + detail rows) | +| `web/src/app/api/guilds/[guildId]/audit-log/route.ts` | Next.js API route — proxies guild audit log queries to bot API | | `web/src/components/dashboard/channel-selector.tsx` | Channel picker component — single or multi-select Discord channel picker with Zustand store integration | | `web/src/components/dashboard/role-selector.tsx` | Role picker component — single or multi-select Discord role picker with color dots | | `web/src/components/dashboard/array-editor.tsx` | Tag-input component for editing string arrays (Enter to add, Backspace to remove) | @@ -64,6 +68,7 @@ | `src/modules/afkHandler.js` | AFK message handler — detects AFK mentions, sends inline notices (rate-limited), auto-clears AFK on return, DMs ping summary | | `src/modules/scheduler.js` | Scheduled message poller — cron expression parser (`parseCron`, `getNextCronRun`), due-message dispatcher via `safeSend`, 60s interval started/stopped via `startScheduler`/`stopScheduler` | | `migrations/002_scheduled-messages.cjs` | Migration — creates `scheduled_messages` table (id, guild_id, channel_id, content, cron_expression, next_run, is_one_time, created_by) | +| `migrations/015_audit_logs.cjs` | Migration — creates `audit_logs` table + indexes for guild timeline and retention cleanup | | `config.json` | Default configuration (seeded to DB on first run) | | `.env.example` | Environment variable template | @@ -141,6 +146,7 @@ Duration-based commands (timeout, tempban, slowmode) use `parseDuration()` from | `mod_scheduled_actions` | Scheduled operations (tempban expiry). Polled every 60s by the tempban scheduler | | `afk_status` | Active AFK records — one row per (guild_id, user_id); upserted on `/afk set`, deleted on return or `/afk clear` | | `afk_pings` | Pings logged while a user is AFK — accumulated until the user returns, then DM-summarised and deleted | +| `audit_logs` | Admin audit trail for mutating API actions (guild/user/action/target + JSON details + IP + created_at) | ## How to Add a Module diff --git a/README.md b/README.md index e3a88b9d3..699ae14db 100644 --- a/README.md +++ b/README.md @@ -203,6 +203,13 @@ All configuration lives in `config.json` and can be updated at runtime via the ` **Escalation thresholds** are objects with: `warns` (count), `withinDays` (window), `action` ("timeout" or "ban"), `duration` (for timeout, e.g. "1h"). +### Audit Log (`auditLog`) + +| Key | Type | Description | +|-----|------|-------------| +| `enabled` | boolean | Enable/disable audit logging for mutating authenticated API requests | +| `retentionDays` | number | Data retention window in days for scheduled cleanup (default: 90, `<= 0` disables purge) | + ### Starboard (`starboard`) | Key | Type | Description | diff --git a/config.json b/config.json index 278fefc89..99844163c 100644 --- a/config.json +++ b/config.json @@ -1,7 +1,7 @@ { "ai": { "enabled": true, - "systemPrompt": "You are Volvox Bot, the friendly AI assistant for the Volvox developer community Discord server.\n\nYou're witty, snarky (but warm), and deeply knowledgeable about programming, software development, and tech.\n\nKey traits:\n- Helpful but not boring\n- Can roast people lightly when appropriate\n- Enthusiastic about cool tech and projects\n- Supportive of beginners learning to code\n- Concise - this is Discord, not an essay\n\nIf asked about your own infrastructure, model, or internals — say you don't know the specifics\nand suggest asking a server admin. Don't guess or speculate about what you run on.\n\nCRITICAL RULES:\n- NEVER type @everyone or @here — these ping hundreds of people\n- NEVER use mass mention pings under any circumstances\n- If you need to address the group, say \"everyone\" or \"folks\" without the @ symbol\n\nKeep responses under 2000 chars. Use Discord markdown when helpful.", + "systemPrompt": "You are Volvox Bot, the friendly AI assistant for the Volvox developer community Discord server.\n\nYou're witty, snarky (but warm), and deeply knowledgeable about programming, software development, and tech.\n\nKey traits:\n- Helpful but not boring\n- Can roast people lightly when appropriate\n- Enthusiastic about cool tech and projects\n- Supportive of beginners learning to code\n- Concise - this is Discord, not an essay\n\nIf asked about your own infrastructure, model, or internals \u2014 say you don't know the specifics\nand suggest asking a server admin. Don't guess or speculate about what you run on.\n\nCRITICAL RULES:\n- NEVER type @everyone or @here \u2014 these ping hundreds of people\n- NEVER use mass mention pings under any circumstances\n- If you need to address the group, say \"everyone\" or \"folks\" without the @ symbol\n\nKeep responses under 2000 chars. Use Discord markdown when helpful.", "channels": [], "historyLength": 20, "historyTTLDays": 30, @@ -43,7 +43,7 @@ "welcome": { "enabled": true, "channelId": "1438631182379253814", - "message": "Welcome to Volvox, {user}! 🌱 You're member #{memberCount}!\n\nWe're a community of developers building cool stuff together. Feel free to introduce yourself!\n\nCheck out <#1446317676988465242> to see what we're working on, share your projects in <#1444154471704957069>, or just say hi in <#1438631182379253814>.\n\nHave questions? Just ask — we're here to help. 💚", + "message": "Welcome to Volvox, {user}! \ud83c\udf31 You're member #{memberCount}!\n\nWe're a community of developers building cool stuff together. Feel free to introduce yourself!\n\nCheck out <#1446317676988465242> to see what we're working on, share your projects in <#1444154471704957069>, or just say hi in <#1438631182379253814>.\n\nHave questions? Just ask \u2014 we're here to help. \ud83d\udc9a", "dynamic": { "enabled": true, "timezone": "America/New_York", @@ -211,19 +211,19 @@ "activityBadges": [ { "days": 90, - "label": "👑 Legend" + "label": "\ud83d\udc51 Legend" }, { "days": 30, - "label": "🌳 Veteran" + "label": "\ud83c\udf33 Veteran" }, { "days": 7, - "label": "🌿 Regular" + "label": "\ud83c\udf3f Regular" }, { "days": 0, - "label": "🌱 Newcomer" + "label": "\ud83c\udf31 Newcomer" } ] }, @@ -260,5 +260,9 @@ "channelId": null, "staleAfterDays": 7, "xpReward": 50 + }, + "auditLog": { + "enabled": true, + "retentionDays": 90 } } diff --git a/migrations/015_audit_logs.cjs b/migrations/015_audit_logs.cjs new file mode 100644 index 000000000..b5114d7b5 --- /dev/null +++ b/migrations/015_audit_logs.cjs @@ -0,0 +1,39 @@ +/** + * Migration 015 — Audit Logs + * Creates the audit_logs table for tracking admin actions in the web dashboard. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/123 + */ + +'use strict'; + +/** + * @param {import('pg').Pool} pool + */ +async function up(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS audit_logs ( + id SERIAL PRIMARY KEY, + guild_id VARCHAR(32) NOT NULL, + user_id VARCHAR(32) NOT NULL, + action VARCHAR(128) NOT NULL, + target_type VARCHAR(64), + target_id VARCHAR(64), + details JSONB, + ip_address VARCHAR(45), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_audit_logs_guild_created + ON audit_logs(guild_id, created_at DESC); + `); + + await pool.query(` + CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at + ON audit_logs(created_at); + `); +} + +module.exports = { up }; diff --git a/src/api/index.js b/src/api/index.js index 938d2f378..dc6474bcf 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -4,7 +4,9 @@ */ import { Router } from 'express'; +import { auditLogMiddleware } from './middleware/auditLog.js'; import { requireAuth } from './middleware/auth.js'; +import auditLogRouter from './routes/auditLog.js'; import authRouter from './routes/auth.js'; import communityRouter from './routes/community.js'; import configRouter from './routes/config.js'; @@ -28,25 +30,29 @@ router.use('/community', communityRouter); router.use('/auth', authRouter); // Global config routes — require API secret or OAuth2 JWT -router.use('/config', requireAuth(), configRouter); +router.use('/config', requireAuth(), auditLogMiddleware(), configRouter); // Member management routes — require API secret or OAuth2 JWT // (mounted before guilds to handle /:id/members/* before the basic guilds endpoint) -router.use('/guilds', requireAuth(), membersRouter); +router.use('/guilds', requireAuth(), auditLogMiddleware(), membersRouter); // Conversation routes — require API secret or OAuth2 JWT // (mounted before guilds to handle /:id/conversations/* before the catch-all guild endpoint) -router.use('/guilds/:id/conversations', requireAuth(), conversationsRouter); +router.use('/guilds/:id/conversations', requireAuth(), auditLogMiddleware(), conversationsRouter); // Ticket routes — require API secret or OAuth2 JWT // (mounted before guilds to handle /:id/tickets/* before the catch-all guild endpoint) -router.use('/guilds', requireAuth(), ticketsRouter); +router.use('/guilds', requireAuth(), auditLogMiddleware(), ticketsRouter); // Guild routes — require API secret or OAuth2 JWT -router.use('/guilds', requireAuth(), guildsRouter); +router.use('/guilds', requireAuth(), auditLogMiddleware(), guildsRouter); // Moderation routes — require API secret or OAuth2 JWT -router.use('/moderation', requireAuth(), moderationRouter); +router.use('/moderation', requireAuth(), auditLogMiddleware(), moderationRouter); + +// Audit log routes — require API secret or OAuth2 JWT +// GET-only; no audit middleware needed (reads are not mutating actions) +router.use('/guilds', requireAuth(), auditLogRouter); // Webhook routes — require API secret or OAuth2 JWT (endpoint further restricts to api-secret) router.use('/webhooks', requireAuth(), webhooksRouter); diff --git a/src/api/middleware/auditLog.js b/src/api/middleware/auditLog.js new file mode 100644 index 000000000..75c5a27a1 --- /dev/null +++ b/src/api/middleware/auditLog.js @@ -0,0 +1,243 @@ +/** + * Audit Log Middleware + * Intercepts mutating requests (POST/PUT/PATCH/DELETE) on authenticated routes + * and records audit entries non-blockingly. + */ + +import { info, error as logError } from '../../logger.js'; +import { getConfig } from '../../modules/config.js'; +import { maskSensitiveFields } from '../utils/configAllowlist.js'; + +/** HTTP methods considered mutating */ +const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +/** + * Derive an action string from the HTTP method and request path. + * + * @param {string} method - HTTP method (e.g. 'PUT') + * @param {string} path - Request path (e.g. '/api/v1/guilds/123/config') + * @returns {string} Dot-separated action identifier + */ +export function deriveAction(method, path) { + // Normalise: strip /api/v1 prefix and trailing slash + const cleaned = path.replace(/^\/api\/v1\/?/, '').replace(/\/$/, ''); + const segments = cleaned.split('/').filter(Boolean); + + // Common patterns: + // PUT guilds/:id/config → config.update + // PUT guilds/:id/members/:memberId/xp → members.xp_update + // POST moderation/warn → moderation.create + + if (segments.length === 0) return `${method.toLowerCase()}.unknown`; + + // Skip 'guilds' + guild ID prefix when present + let i = 0; + if (segments[0] === 'guilds' && segments.length > 1) { + i = 2; // skip 'guilds' and guild ID + } + + const rest = segments.slice(i); + if (rest.length === 0) return 'guild.update'; + + const resource = rest[0]; + const sub = rest.length > 2 ? rest[rest.length - 1] : null; + + const methodVerb = + method === 'POST' + ? 'create' + : method === 'PUT' || method === 'PATCH' + ? 'update' + : method === 'DELETE' + ? 'delete' + : method.toLowerCase(); + + if (sub) { + return `${resource}.${sub}_${methodVerb}`; + } + + return `${resource}.${methodVerb}`; +} + +/** + * Extract the guild ID from the request path if present. + * + * @param {string} path - Request path + * @returns {string|null} Guild ID or null + */ +function extractGuildId(path) { + const match = path.match(/\/guilds\/([^/]+)/); + return match ? match[1] : null; +} + +/** + * Compute a shallow diff between two objects, returning only changed keys. + * + * @param {Object} before - Previous state + * @param {Object} after - New state + * @returns {Object} Object with `before` and `after` containing only differing keys + */ +export function computeConfigDiff(before, after) { + const diff = { before: {}, after: {} }; + const allKeys = new Set([...Object.keys(before || {}), ...Object.keys(after || {})]); + + for (const key of allKeys) { + const b = JSON.stringify(before?.[key]); + const a = JSON.stringify(after?.[key]); + if (b !== a) { + diff.before[key] = before?.[key]; + diff.after[key] = after?.[key]; + } + } + + return diff; +} + +/** + * Insert an audit log entry into the database. Fire-and-forget (non-blocking). + * + * @param {import('pg').Pool} pool - Database connection pool + * @param {Object} entry - Audit log entry + */ +function insertAuditEntry(pool, entry) { + const { guildId, userId, action, targetType, targetId, details, ipAddress } = entry; + + try { + const result = pool.query( + `INSERT INTO audit_logs (guild_id, user_id, action, target_type, target_id, details, ip_address) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, + [ + guildId || 'global', + userId, + action, + targetType || null, + targetId || null, + details ? JSON.stringify(details) : null, + ipAddress || null, + ], + ); + + if (result && typeof result.then === 'function') { + result + .then(() => { + info('Audit log entry created', { action, guildId, userId }); + }) + .catch((err) => { + logError('Failed to insert audit log entry', { error: err.message, action, guildId }); + }); + } + } catch (err) { + logError('Failed to insert audit log entry', { error: err.message, action, guildId }); + } +} + +/** + * Express middleware that records audit log entries for mutating requests. + * Non-blocking — the response is not delayed by the audit write. + * + * @returns {import('express').RequestHandler} + */ +export function auditLogMiddleware() { + return (req, res, next) => { + // Prevent double-execution: multiple routers can be mounted at the same path prefix + // (e.g. /guilds mounts membersRouter, ticketsRouter, guildsRouter in sequence). + // Only the first matching mount should attach the audit handler. + if (req._auditLogAttached) { + return next(); + } + + // Only audit mutating methods + if (!MUTATING_METHODS.has(req.method)) { + return next(); + } + + // Strip query string once — req.originalUrl includes it (e.g. /api/v1/guilds/123/config?limit=25) + const cleanPath = (req.originalUrl || req.path).split('?')[0]; + const guildId = extractGuildId(cleanPath) || req.body?.guildId || null; + + // Check if audit logging is enabled in config (guild-scoped when available) + const config = getConfig(guildId || undefined); + if (config.auditLog && config.auditLog.enabled === false) { + return next(); + } + + const pool = req.app.locals.dbPool; + if (!pool) { + return next(); + } + + req._auditLogAttached = true; + + const userId = req.user?.userId || req.authMethod || 'unknown'; + const action = deriveAction(req.method, cleanPath); + const ipAddress = req.ip || req.socket?.remoteAddress; + + // For config updates, capture before state to compute diff. + // Use guild-scoped config for accurate before/after snapshots. + const isConfigUpdate = + cleanPath.includes('/config') && (req.method === 'PUT' || req.method === 'PATCH'); + + let beforeConfig = null; + if (isConfigUpdate) { + try { + beforeConfig = structuredClone(getConfig(guildId)); + } catch { + // Non-critical — proceed without diff + } + } + + // Hook into response finish to capture the outcome + res.on('finish', () => { + // Only log successful mutations (2xx/3xx) + if (res.statusCode >= 400) return; + + const details = { method: req.method, path: cleanPath }; + + // Include request body with sensitive fields masked + if (req.body && typeof req.body === 'object' && Object.keys(req.body).length > 0) { + details.body = maskSensitiveFields(req.body); + } + + // Compute config diff for config updates, masking sensitive fields in both snapshots + if (isConfigUpdate && beforeConfig) { + try { + const afterConfig = getConfig(guildId); + const diff = computeConfigDiff(beforeConfig, afterConfig); + if (Object.keys(diff.before).length > 0 || Object.keys(diff.after).length > 0) { + details.configDiff = { + before: maskSensitiveFields(diff.before), + after: maskSensitiveFields(diff.after), + }; + } + } catch { + // Non-critical + } + } + + // Derive target type/id from path + let targetType = null; + let targetId = null; + const pathSegments = cleanPath + .replace(/^\/api\/v1\/?/, '') + .split('/') + .filter(Boolean); + + // Pattern: guilds/:id//:resourceId + if (pathSegments.length >= 4 && pathSegments[0] === 'guilds') { + targetType = pathSegments[2]; + targetId = pathSegments[3]; + } + + insertAuditEntry(pool, { + guildId, + userId, + action, + targetType, + targetId, + details, + ipAddress, + }); + }); + + next(); + }; +} diff --git a/src/api/routes/auditLog.js b/src/api/routes/auditLog.js new file mode 100644 index 000000000..6930b1bea --- /dev/null +++ b/src/api/routes/auditLog.js @@ -0,0 +1,126 @@ +/** + * Audit Log API Routes + * Paginated, filterable audit log retrieval for dashboard consumption. + * + * @see https://github.com/VolvoxLLC/volvox-bot/issues/123 + */ + +import { Router } from 'express'; +import { error as logError } from '../../logger.js'; +import { rateLimit } from '../middleware/rateLimit.js'; +import { requireGuildAdmin, validateGuild } from './guilds.js'; + +const router = Router(); + +/** Rate limiter for audit log endpoints — 30 req/min per IP. */ +const auditRateLimit = rateLimit({ windowMs: 60 * 1000, max: 30 }); + +/** + * Helper to get the database pool from app.locals. + * + * @param {import('express').Request} req + * @returns {import('pg').Pool | null} + */ +function getDbPool(req) { + return req.app.locals.dbPool || null; +} + +/** + * Normalize a query filter value to a non-empty string. + * Express query params can be arrays/objects for repeated or nested keys. + * We ignore non-string values to avoid passing invalid types to pg. + * + * @param {unknown} value + * @returns {string|null} + */ +function toFilterString(value) { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +// ─── GET /:id/audit-log ────────────────────────────────────────────────────── + +/** + * GET /:id/audit-log — Paginated audit log with filters. + * + * Query params: + * action — Filter by action type (e.g. 'config.update') + * userId — Filter by admin user ID + * startDate — ISO timestamp lower bound + * endDate — ISO timestamp upper bound + * limit — Items per page (default 25, max 100) + * offset — Offset for pagination (default 0) + */ +router.get('/:id/audit-log', auditRateLimit, requireGuildAdmin, validateGuild, async (req, res) => { + const { id: guildId } = req.params; + const pool = getDbPool(req); + if (!pool) return res.status(503).json({ error: 'Database not available' }); + + const limit = Math.min(100, Math.max(1, Number.parseInt(req.query.limit, 10) || 25)); + const offset = Math.max(0, Number.parseInt(req.query.offset, 10) || 0); + + try { + const conditions = ['guild_id = $1']; + const params = [guildId]; + let paramIndex = 2; + + const actionFilter = toFilterString(req.query.action); + if (actionFilter) { + conditions.push(`action = $${paramIndex}`); + params.push(actionFilter); + paramIndex++; + } + + const userIdFilter = toFilterString(req.query.userId); + if (userIdFilter) { + conditions.push(`user_id = $${paramIndex}`); + params.push(userIdFilter); + paramIndex++; + } + + if (req.query.startDate) { + const start = new Date(req.query.startDate); + if (!Number.isNaN(start.getTime())) { + conditions.push(`created_at >= $${paramIndex}`); + params.push(start.toISOString()); + paramIndex++; + } + } + + if (req.query.endDate) { + const end = new Date(req.query.endDate); + if (!Number.isNaN(end.getTime())) { + conditions.push(`created_at <= $${paramIndex}`); + params.push(end.toISOString()); + paramIndex++; + } + } + + const whereClause = conditions.join(' AND '); + + const [countResult, entriesResult] = await Promise.all([ + pool.query(`SELECT COUNT(*)::int AS total FROM audit_logs WHERE ${whereClause}`, params), + pool.query( + `SELECT id, guild_id, user_id, action, target_type, target_id, details, ip_address, created_at + FROM audit_logs + WHERE ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`, + [...params, limit, offset], + ), + ]); + + res.json({ + entries: entriesResult.rows, + total: countResult.rows[0].total, + limit, + offset, + }); + } catch (err) { + logError('Failed to fetch audit log', { guildId, error: err.message }); + res.status(500).json({ error: 'Failed to fetch audit log' }); + } +}); + +export default router; diff --git a/src/api/utils/configAllowlist.js b/src/api/utils/configAllowlist.js index 3ad6d7a21..d291cb424 100644 --- a/src/api/utils/configAllowlist.js +++ b/src/api/utils/configAllowlist.js @@ -25,6 +25,7 @@ export const SAFE_CONFIG_KEYS = new Set([ 'github', 'challenges', 'review', + 'auditLog', ]); export const READABLE_CONFIG_KEYS = [...SAFE_CONFIG_KEYS, 'logging']; diff --git a/src/api/utils/configValidation.js b/src/api/utils/configValidation.js index 5ef14c734..b823f2cf0 100644 --- a/src/api/utils/configValidation.js +++ b/src/api/utils/configValidation.js @@ -141,6 +141,13 @@ export const CONFIG_SCHEMA = { moderationLogChannel: { type: 'string', nullable: true }, }, }, + auditLog: { + type: 'object', + properties: { + enabled: { type: 'boolean' }, + retentionDays: { type: 'number' }, + }, + }, }; /** diff --git a/src/modules/scheduler.js b/src/modules/scheduler.js index db2137f57..1fbf5c198 100644 --- a/src/modules/scheduler.js +++ b/src/modules/scheduler.js @@ -10,6 +10,7 @@ import { info, error as logError, warn as logWarn } from '../logger.js'; import { runMaintenance } from '../utils/dbMaintenance.js'; import { safeSend } from '../utils/safeSend.js'; import { checkDailyChallenge } from './challengeScheduler.js'; +import { getConfig } from './config.js'; import { closeExpiredPolls } from './pollHandler.js'; import { expireStaleReviews } from './reviewHandler.js'; import { checkAutoClose } from './ticketHandler.js'; @@ -204,6 +205,10 @@ async function pollScheduledMessages(client) { logError('DB maintenance task failed', { error: err.message }); }); } + // Purge expired audit log entries (every 6 hours / 360th tick) + if (tickCount % 360 === 0) { + await purgeExpiredAuditLogs(); + } } catch (err) { logError('Scheduler poll error', { error: err.message }); } finally { @@ -211,6 +216,30 @@ async function pollScheduledMessages(client) { } } +/** + * Purge audit log entries older than the configured retention period. + * Runs as a periodic maintenance task within the scheduler. + */ +async function purgeExpiredAuditLogs() { + try { + const pool = getPool(); + const config = getConfig(); + const retentionDays = Number(config?.auditLog?.retentionDays); + if (!retentionDays || retentionDays <= 0 || !Number.isFinite(retentionDays)) return; + + const { rowCount } = await pool.query( + 'DELETE FROM audit_logs WHERE created_at < NOW() - make_interval(days => $1)', + [retentionDays], + ); + + if (rowCount > 0) { + info('Purged expired audit log entries', { deleted: rowCount, retentionDays }); + } + } catch (err) { + logError('Failed to purge audit log entries', { error: err.message }); + } +} + /** * Start the scheduled message polling interval. * Polls every 60 seconds for due messages. diff --git a/tests/api/middleware/auditLog.test.js b/tests/api/middleware/auditLog.test.js new file mode 100644 index 000000000..932a7322b --- /dev/null +++ b/tests/api/middleware/auditLog.test.js @@ -0,0 +1,252 @@ +/** + * Tests for src/api/middleware/auditLog.js + * Covers action derivation, config diff computation, and middleware behaviour. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../../src/modules/config.js', () => { + let currentConfig = { auditLog: { enabled: true, retentionDays: 90 } }; + return { + getConfig: vi.fn(() => currentConfig), + _setTestConfig: (c) => { + currentConfig = c; + }, + }; +}); + +import { + auditLogMiddleware, + computeConfigDiff, + deriveAction, +} from '../../../src/api/middleware/auditLog.js'; +import { _setTestConfig, getConfig } from '../../../src/modules/config.js'; + +describe('auditLog middleware', () => { + afterEach(() => { + vi.clearAllMocks(); + _setTestConfig({ auditLog: { enabled: true, retentionDays: 90 } }); + }); + + // ─── deriveAction ───────────────────────────────────────────── + + describe('deriveAction', () => { + it('should derive config.update for PUT config', () => { + expect(deriveAction('PUT', '/api/v1/guilds/123/config')).toBe('config.update'); + }); + + it('should derive config.update for PATCH config', () => { + expect(deriveAction('PATCH', '/api/v1/guilds/123/config')).toBe('config.update'); + }); + + it('should derive members.update for member operations', () => { + expect(deriveAction('PATCH', '/api/v1/guilds/123/members/456')).toBe('members.update'); + }); + + it('should derive moderation.create for POST moderation', () => { + expect(deriveAction('POST', '/api/v1/moderation/warn')).toBe('moderation.create'); + }); + + it('should handle unknown paths gracefully', () => { + const action = deriveAction('POST', '/api/v1'); + expect(action).toBe('post.unknown'); + }); + + it('should derive guild.update for guild-level operations', () => { + expect(deriveAction('PUT', '/api/v1/guilds/123')).toBe('guild.update'); + }); + }); + + // ─── computeConfigDiff ──────────────────────────────────────── + + describe('computeConfigDiff', () => { + it('should detect changed keys', () => { + const before = { ai: { enabled: true }, welcome: { enabled: false } }; + const after = { ai: { enabled: false }, welcome: { enabled: false } }; + + const diff = computeConfigDiff(before, after); + expect(diff.before).toHaveProperty('ai'); + expect(diff.after).toHaveProperty('ai'); + expect(diff.before).not.toHaveProperty('welcome'); + }); + + it('should detect added keys', () => { + const before = { ai: { enabled: true } }; + const after = { ai: { enabled: true }, newSection: { foo: 'bar' } }; + + const diff = computeConfigDiff(before, after); + expect(diff.after).toHaveProperty('newSection'); + expect(diff.before.newSection).toBeUndefined(); + }); + + it('should detect removed keys', () => { + const before = { ai: { enabled: true }, old: { x: 1 } }; + const after = { ai: { enabled: true } }; + + const diff = computeConfigDiff(before, after); + expect(diff.before).toHaveProperty('old'); + expect(diff.after.old).toBeUndefined(); + }); + + it('should return empty diff when configs are identical', () => { + const config = { ai: { enabled: true }, welcome: { enabled: false } }; + const diff = computeConfigDiff(config, config); + expect(Object.keys(diff.before)).toHaveLength(0); + expect(Object.keys(diff.after)).toHaveLength(0); + }); + + it('should handle null/undefined inputs', () => { + const diff = computeConfigDiff(null, { ai: true }); + expect(diff.after).toHaveProperty('ai'); + }); + }); + + // ─── middleware behaviour ───────────────────────────────────── + + describe('middleware', () => { + function createMockReq(method = 'POST', path = '/api/v1/guilds/123/config') { + const listeners = {}; + return { + method, + path, + originalUrl: path, + body: { ai: { enabled: true } }, + user: { userId: 'user1' }, + authMethod: 'oauth', + ip: '127.0.0.1', + socket: { remoteAddress: '127.0.0.1' }, + app: { + locals: { + dbPool: { + query: vi.fn().mockResolvedValue({}), + }, + }, + }, + on: vi.fn((event, cb) => { + listeners[event] = cb; + }), + _listeners: listeners, + }; + } + + function createMockRes(statusCode = 200) { + const listeners = {}; + return { + statusCode, + on: vi.fn((event, cb) => { + listeners[event] = cb; + }), + _listeners: listeners, + }; + } + + it('should skip non-mutating methods', () => { + const middleware = auditLogMiddleware(); + const req = createMockReq('GET'); + const res = createMockRes(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.on).not.toHaveBeenCalled(); + }); + + it('should call next immediately (non-blocking)', () => { + const middleware = auditLogMiddleware(); + const req = createMockReq(); + const res = createMockRes(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + }); + + it('should register a finish listener on the response', () => { + const middleware = auditLogMiddleware(); + const req = createMockReq(); + const res = createMockRes(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(res.on).toHaveBeenCalledWith('finish', expect.any(Function)); + }); + + it('should use guild-scoped config for the enabled check', () => { + const middleware = auditLogMiddleware(); + const req = createMockReq('POST', '/api/v1/guilds/123/config'); + const res = createMockRes(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(getConfig).toHaveBeenCalledWith('123'); + }); + + it('should insert audit entry on successful response finish', () => { + const middleware = auditLogMiddleware(); + const req = createMockReq(); + const res = createMockRes(200); + const next = vi.fn(); + + middleware(req, res, next); + + // Simulate response finish + const finishCb = res._listeners.finish; + finishCb(); + + expect(req.app.locals.dbPool.query).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO audit_logs'), + expect.arrayContaining(['123', 'user1']), + ); + }); + + it('should not insert audit entry on failed response (4xx)', () => { + const middleware = auditLogMiddleware(); + const req = createMockReq(); + const res = createMockRes(400); + const next = vi.fn(); + + middleware(req, res, next); + + const finishCb = res._listeners.finish; + finishCb(); + + expect(req.app.locals.dbPool.query).not.toHaveBeenCalled(); + }); + + it('should skip when auditLog is disabled in config', () => { + _setTestConfig({ auditLog: { enabled: false } }); + + const middleware = auditLogMiddleware(); + const req = createMockReq(); + const res = createMockRes(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.on).not.toHaveBeenCalled(); + }); + + it('should skip when dbPool is unavailable', () => { + const middleware = auditLogMiddleware(); + const req = createMockReq(); + req.app.locals.dbPool = null; + const res = createMockRes(); + const next = vi.fn(); + + middleware(req, res, next); + + expect(next).toHaveBeenCalledOnce(); + expect(res.on).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/api/routes/auditLog.test.js b/tests/api/routes/auditLog.test.js new file mode 100644 index 000000000..0b0eee8cf --- /dev/null +++ b/tests/api/routes/auditLog.test.js @@ -0,0 +1,265 @@ +/** + * Tests for src/api/routes/auditLog.js + * Covers audit log listing, pagination, filtering, and auth. + */ +import request from 'supertest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/logger.js', () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), +})); + +vi.mock('../../../src/modules/config.js', () => ({ + getConfig: vi.fn().mockReturnValue({ + permissions: { botOwners: [] }, + auditLog: { enabled: true, retentionDays: 90 }, + }), + setConfigValue: vi.fn(), +})); + +vi.mock('../../../src/api/middleware/oauthJwt.js', () => ({ + handleOAuthJwt: vi.fn().mockResolvedValue(false), + stopJwtCleanup: vi.fn(), +})); + +import { createApp } from '../../../src/api/server.js'; + +const TEST_SECRET = 'test-audit-secret'; + +function authed(req) { + return req.set('x-api-secret', TEST_SECRET); +} + +describe('auditLog routes', () => { + let app; + let mockPool; + + const mockGuild = { + id: 'guild1', + name: 'Test Server', + iconURL: () => 'https://cdn.example.com/icon.png', + memberCount: 100, + channels: { cache: new Map() }, + roles: { cache: new Map() }, + members: { cache: new Map() }, + }; + + beforeEach(() => { + vi.stubEnv('BOT_API_SECRET', TEST_SECRET); + + mockPool = { + query: vi.fn().mockResolvedValue({ rows: [] }), + connect: vi.fn(), + }; + + const client = { + guilds: { cache: new Map([['guild1', mockGuild]]) }, + ws: { status: 0, ping: 42 }, + user: { tag: 'Bot#1234' }, + }; + + app = createApp(client, mockPool); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + // ─── Auth ────────────────────────────────────────────────────── + + describe('authentication', () => { + it('should return 401 without auth', async () => { + const res = await request(app).get('/api/v1/guilds/guild1/audit-log'); + expect(res.status).toBe(401); + }); + + it('should return 404 for unknown guild', async () => { + const res = await authed(request(app).get('/api/v1/guilds/unknown-guild/audit-log')); + expect(res.status).toBe(404); + }); + }); + + // ─── GET /:id/audit-log ─────────────────────────────────────── + + describe('GET /:id/audit-log', () => { + it('should return empty entries list', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/audit-log')); + expect(res.status).toBe(200); + expect(res.body.entries).toEqual([]); + expect(res.body.total).toBe(0); + expect(res.body.limit).toBe(25); + expect(res.body.offset).toBe(0); + }); + + it('should return entries with pagination', async () => { + const mockEntries = [ + { + id: 1, + guild_id: 'guild1', + user_id: 'user1', + action: 'config.update', + target_type: null, + target_id: null, + details: { method: 'PUT', path: '/api/v1/guilds/guild1/config' }, + ip_address: '127.0.0.1', + created_at: '2026-02-28T12:00:00Z', + }, + ]; + + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 50 }] }) + .mockResolvedValueOnce({ rows: mockEntries }); + + const res = await authed( + request(app).get('/api/v1/guilds/guild1/audit-log?limit=10&offset=0'), + ); + + expect(res.status).toBe(200); + expect(res.body.entries).toHaveLength(1); + expect(res.body.total).toBe(50); + expect(res.body.limit).toBe(10); + expect(res.body.offset).toBe(0); + }); + + it('should respect limit cap of 100', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/audit-log?limit=500')); + + expect(res.status).toBe(200); + expect(res.body.limit).toBe(100); + }); + + it('should filter by action', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 5 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed( + request(app).get('/api/v1/guilds/guild1/audit-log?action=config.update'), + ); + + expect(res.status).toBe(200); + + // Verify the query included action filter + const countCall = mockPool.query.mock.calls[0]; + expect(countCall[0]).toContain('action = $2'); + expect(countCall[1]).toContain('config.update'); + }); + + it('should filter by userId', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 3 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/audit-log?userId=user42')); + + expect(res.status).toBe(200); + + const countCall = mockPool.query.mock.calls[0]; + expect(countCall[0]).toContain('user_id = $2'); + expect(countCall[1]).toContain('user42'); + }); + + it('should ignore non-string action filters', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed( + request(app).get( + '/api/v1/guilds/guild1/audit-log?action=config.update&action=members.delete', + ), + ); + + expect(res.status).toBe(200); + + const countCall = mockPool.query.mock.calls[0]; + expect(countCall[0]).not.toContain('action ='); + expect(countCall[1]).toEqual(['guild1']); + }); + + it('should ignore non-string userId filters', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 0 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed( + request(app).get('/api/v1/guilds/guild1/audit-log?userId=user1&userId=user2'), + ); + + expect(res.status).toBe(200); + + const countCall = mockPool.query.mock.calls[0]; + expect(countCall[0]).not.toContain('user_id ='); + expect(countCall[1]).toEqual(['guild1']); + }); + + it('should filter by date range', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 2 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed( + request(app).get( + '/api/v1/guilds/guild1/audit-log?startDate=2026-01-01T00:00:00Z&endDate=2026-01-31T23:59:59Z', + ), + ); + + expect(res.status).toBe(200); + + const countCall = mockPool.query.mock.calls[0]; + expect(countCall[0]).toContain('created_at >='); + expect(countCall[0]).toContain('created_at <='); + }); + + it('should combine multiple filters', async () => { + mockPool.query + .mockResolvedValueOnce({ rows: [{ total: 1 }] }) + .mockResolvedValueOnce({ rows: [] }); + + const res = await authed( + request(app).get( + '/api/v1/guilds/guild1/audit-log?action=config.update&userId=user1&startDate=2026-01-01T00:00:00Z', + ), + ); + + expect(res.status).toBe(200); + + const countCall = mockPool.query.mock.calls[0]; + expect(countCall[0]).toContain('action = $2'); + expect(countCall[0]).toContain('user_id = $3'); + expect(countCall[0]).toContain('created_at >= $4'); + }); + + it('should return 503 when database is unavailable', async () => { + // Create app without dbPool + const client = { + guilds: { cache: new Map([['guild1', mockGuild]]) }, + ws: { status: 0, ping: 42 }, + user: { tag: 'Bot#1234' }, + }; + const appNoDb = createApp(client, null); + + const res = await authed(request(appNoDb).get('/api/v1/guilds/guild1/audit-log')); + expect(res.status).toBe(503); + }); + + it('should return 500 on database error', async () => { + mockPool.query.mockRejectedValue(new Error('DB connection lost')); + + const res = await authed(request(app).get('/api/v1/guilds/guild1/audit-log')); + expect(res.status).toBe(500); + expect(res.body.error).toBe('Failed to fetch audit log'); + }); + }); +}); diff --git a/tests/api/routes/guilds.test.js b/tests/api/routes/guilds.test.js index ca92f8c80..bf711d0f0 100644 --- a/tests/api/routes/guilds.test.js +++ b/tests/api/routes/guilds.test.js @@ -442,6 +442,16 @@ describe('guilds routes', () => { describe('PATCH /:id/config', () => { it('should update config value', async () => { + // auditLogMiddleware now calls getConfig() for enabled check, before snapshot, + // and after snapshot; provide pass-through values so the route handler gets + // the intended guild-scoped config response. + getConfig.mockReturnValueOnce({}); + getConfig.mockReturnValueOnce({ + ai: { enabled: true, systemPrompt: 'claude-3', historyLength: 20 }, + }); + getConfig.mockReturnValueOnce({ + ai: { enabled: true, systemPrompt: 'claude-4', historyLength: 20 }, + }); getConfig.mockReturnValueOnce({ ai: { enabled: true, systemPrompt: 'claude-4', historyLength: 20 }, }); diff --git a/tests/api/utils/configValidation.test.js b/tests/api/utils/configValidation.test.js index f5c2a2516..adcba1842 100644 --- a/tests/api/utils/configValidation.test.js +++ b/tests/api/utils/configValidation.test.js @@ -104,8 +104,48 @@ describe('configValidation', () => { describe('CONFIG_SCHEMA', () => { it('should have schemas for all expected top-level sections', () => { expect(Object.keys(CONFIG_SCHEMA)).toEqual( - expect.arrayContaining(['ai', 'welcome', 'spam', 'moderation', 'triage']), + expect.arrayContaining(['ai', 'welcome', 'spam', 'moderation', 'triage', 'auditLog']), ); }); }); + + describe('auditLog schema validation', () => { + it('should accept valid auditLog.enabled boolean', () => { + expect(validateSingleValue('auditLog.enabled', true)).toEqual([]); + expect(validateSingleValue('auditLog.enabled', false)).toEqual([]); + }); + + it('should reject non-boolean auditLog.enabled', () => { + const errors = validateSingleValue('auditLog.enabled', 'yes'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('expected boolean'); + }); + + it('should accept valid auditLog.retentionDays number', () => { + expect(validateSingleValue('auditLog.retentionDays', 90)).toEqual([]); + expect(validateSingleValue('auditLog.retentionDays', 365)).toEqual([]); + }); + + it('should reject non-number auditLog.retentionDays', () => { + const errors = validateSingleValue('auditLog.retentionDays', '90'); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('expected finite number'); + }); + + it('should reject NaN auditLog.retentionDays', () => { + const errors = validateSingleValue('auditLog.retentionDays', NaN); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('expected finite number'); + }); + + it('should reject unknown keys in auditLog object', () => { + const errors = validateSingleValue('auditLog', { + enabled: true, + retentionDays: 90, + badKey: 'nope', + }); + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('unknown config key'); + }); + }); }); diff --git a/web/src/app/api/guilds/[guildId]/audit-log/route.ts b/web/src/app/api/guilds/[guildId]/audit-log/route.ts new file mode 100644 index 000000000..a57db5c24 --- /dev/null +++ b/web/src/app/api/guilds/[guildId]/audit-log/route.ts @@ -0,0 +1,45 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { + authorizeGuildAdmin, + buildUpstreamUrl, + getBotApiConfig, + proxyToBotApi, +} from '@/lib/bot-api-proxy'; + +const LOG_PREFIX = '[api/guilds/:guildId/audit-log]'; + +export const dynamic = 'force-dynamic'; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ guildId: string }> }, +) { + const { guildId } = await params; + if (!guildId) { + return NextResponse.json({ error: 'Missing guildId' }, { status: 400 }); + } + + const authError = await authorizeGuildAdmin(request, guildId, LOG_PREFIX); + if (authError) return authError; + + const config = getBotApiConfig(LOG_PREFIX); + if (config instanceof NextResponse) return config; + + const upstreamUrl = buildUpstreamUrl( + config.baseUrl, + `/guilds/${encodeURIComponent(guildId)}/audit-log`, + LOG_PREFIX, + ); + if (upstreamUrl instanceof NextResponse) return upstreamUrl; + + const allowedParams = ['limit', 'offset', 'action', 'userId', 'startDate', 'endDate']; + for (const key of allowedParams) { + const value = request.nextUrl.searchParams.get(key); + if (value !== null) { + upstreamUrl.searchParams.set(key, value); + } + } + + return proxyToBotApi(upstreamUrl, config.secret, LOG_PREFIX, 'Failed to fetch audit log'); +} diff --git a/web/src/app/dashboard/audit-log/page.tsx b/web/src/app/dashboard/audit-log/page.tsx new file mode 100644 index 000000000..67e4db798 --- /dev/null +++ b/web/src/app/dashboard/audit-log/page.tsx @@ -0,0 +1,453 @@ +'use client'; + +import { ChevronDown, ChevronRight, ClipboardList, RefreshCw, Search, X } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { Fragment, useCallback, useEffect, useRef, useState } from 'react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { useGuildSelection } from '@/hooks/use-guild-selection'; + +interface AuditEntry { + id: number; + guild_id: string; + user_id: string; + action: string; + target_type: string | null; + target_id: string | null; + details: Record | null; + ip_address: string | null; + created_at: string; +} + +interface AuditLogResponse { + entries: AuditEntry[]; + total: number; + limit: number; + offset: number; +} + +function formatDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +/** Map action prefixes to badge colours */ +function actionVariant(action: string): 'default' | 'secondary' | 'destructive' | 'outline' { + if (action.includes('delete')) return 'destructive'; + if (action.includes('create')) return 'default'; + if (action.includes('update')) return 'secondary'; + return 'outline'; +} + +const PAGE_SIZE = 25; + +/** Common action types for the filter dropdown */ +const ACTION_OPTIONS = [ + 'config.update', + 'members.update', + 'moderation.create', + 'moderation.delete', + 'tickets.update', +]; + +export default function AuditLogPage() { + const router = useRouter(); + + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [offset, setOffset] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [actionFilter, setActionFilter] = useState(''); + const [userSearch, setUserSearch] = useState(''); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [expandedRows, setExpandedRows] = useState>(new Set()); + + const searchTimerRef = useRef>(undefined); + const [debouncedUserSearch, setDebouncedUserSearch] = useState(''); + + const abortControllerRef = useRef(null); + const requestIdRef = useRef(0); + + useEffect(() => { + clearTimeout(searchTimerRef.current); + searchTimerRef.current = setTimeout(() => { + setDebouncedUserSearch(userSearch); + setOffset(0); + }, 300); + return () => clearTimeout(searchTimerRef.current); + }, [userSearch]); + + useEffect(() => { + return () => { + abortControllerRef.current?.abort(); + }; + }, []); + + const onGuildChange = useCallback(() => { + setEntries([]); + setTotal(0); + setOffset(0); + setError(null); + setExpandedRows(new Set()); + }, []); + + const guildId = useGuildSelection({ onGuildChange }); + + const onUnauthorized = useCallback(() => router.replace('/login'), [router]); + + const fetchAuditLog = useCallback( + async (opts: { + guildId: string; + action: string; + userId: string; + startDate: string; + endDate: string; + offset: number; + }) => { + abortControllerRef.current?.abort(); + const controller = new AbortController(); + abortControllerRef.current = controller; + const requestId = ++requestIdRef.current; + + setLoading(true); + setError(null); + + try { + const params = new URLSearchParams(); + params.set('limit', String(PAGE_SIZE)); + params.set('offset', String(opts.offset)); + if (opts.action) params.set('action', opts.action); + if (opts.userId) params.set('userId', opts.userId); + if (opts.startDate) params.set('startDate', opts.startDate); + if (opts.endDate) params.set('endDate', opts.endDate); + + const res = await fetch( + `/api/guilds/${encodeURIComponent(opts.guildId)}/audit-log?${params.toString()}`, + { signal: controller.signal }, + ); + + if (requestId !== requestIdRef.current) return; + + if (res.status === 401) { + onUnauthorized(); + return; + } + if (!res.ok) { + throw new Error(`Failed to fetch audit log (${res.status})`); + } + + const data = (await res.json()) as AuditLogResponse; + setEntries(data.entries); + setTotal(data.total); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + if (requestId !== requestIdRef.current) return; + setError(err instanceof Error ? err.message : 'Failed to fetch audit log'); + } finally { + if (requestId === requestIdRef.current) { + setLoading(false); + } + } + }, + [onUnauthorized], + ); + + useEffect(() => { + if (!guildId) return; + void fetchAuditLog({ + guildId, + action: actionFilter, + userId: debouncedUserSearch, + startDate, + endDate, + offset, + }); + }, [guildId, actionFilter, debouncedUserSearch, startDate, endDate, offset, fetchAuditLog]); + + const handleRefresh = useCallback(() => { + if (!guildId) return; + void fetchAuditLog({ + guildId, + action: actionFilter, + userId: debouncedUserSearch, + startDate, + endDate, + offset, + }); + }, [guildId, fetchAuditLog, actionFilter, debouncedUserSearch, startDate, endDate, offset]); + + const toggleRow = useCallback((id: number) => { + setExpandedRows((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + return next; + }); + }, []); + + const handleClearSearch = useCallback(() => { + setUserSearch(''); + setDebouncedUserSearch(''); + setOffset(0); + }, []); + + const currentPage = Math.floor(offset / PAGE_SIZE) + 1; + const totalPages = Math.ceil(total / PAGE_SIZE); + + return ( +
+ {/* Header */} +
+
+

+ + Audit Log +

+

+ Track all admin actions and configuration changes. +

+
+ + +
+ + {/* No guild selected */} + {!guildId && ( +
+

+ Select a server from the sidebar to view the audit log. +

+
+ )} + + {/* Content */} + {guildId && ( + <> + {/* Filters */} +
+
+ + setUserSearch(e.target.value)} + aria-label="Filter audit log by user ID" + /> + {userSearch && ( + + )} +
+ + + + { + setStartDate(e.target.value); + setOffset(0); + }} + aria-label="Start date filter" + /> + + { + setEndDate(e.target.value); + setOffset(0); + }} + aria-label="End date filter" + /> + + {total > 0 && ( + + {total.toLocaleString()} {total === 1 ? 'entry' : 'entries'} + + )} +
+ + {/* Error */} + {error && ( +
+ Error: {error} +
+ )} + + {/* Table */} + {entries.length > 0 ? ( +
+ + + + + Action + User + Target + Date + IP + + + + {entries.map((entry) => { + const isExpanded = expandedRows.has(entry.id); + return ( + + toggleRow(entry.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleRow(entry.id); + } + }} + > + + {isExpanded ? ( + + ) : ( + + )} + + + {entry.action} + + {entry.user_id} + + {entry.target_type && entry.target_id + ? `${entry.target_type}:${entry.target_id}` + : '—'} + + + {formatDate(entry.created_at)} + + + {entry.ip_address || '—'} + + + {isExpanded && entry.details && ( + + +
+                                {JSON.stringify(entry.details, null, 2)}
+                              
+
+
+ )} +
+ ); + })} +
+
+
+ ) : ( + !loading && ( +
+

+ {actionFilter || debouncedUserSearch || startDate || endDate + ? 'No audit entries match your filters.' + : 'No audit log entries found.'} +

+
+ ) + )} + + {/* Pagination */} + {totalPages > 1 && ( +
+ + Page {currentPage} of {totalPages} + +
+ + +
+
+ )} + + )} +
+ ); +} diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index d2b688ee8..7e4a3084e 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -2,6 +2,7 @@ import { Bot, + ClipboardList, LayoutDashboard, MessageSquare, MessagesSquare, @@ -52,6 +53,11 @@ const navigation = [ href: '/dashboard/config', icon: Bot, }, + { + name: 'Audit Log', + href: '/dashboard/audit-log', + icon: ClipboardList, + }, { name: 'Logs', href: '/dashboard/logs',