/** * /api/admin/audit-logs * Accessible admin uniquement (requireAuth + requireAdmin appliqués dans server.js). */ import { Router } from 'express'; import db from '../db/index.js'; const router = Router(); // ── Nettoyage automatique des logs > 30 jours ───────────────────────────── function purgeOldLogs() { db.prepare("DELETE FROM audit_logs WHERE created_at < datetime('now', '-30 days')").run(); } // ── Classification succès / avertissement / échec par action ────────────── // IMPORTANT : garder cette liste synchronisée avec ACTION_STATUS dans // frontend/src/pages/admin/AuditLogsSection.jsx (icônes + libellés). const STATUS_ACTIONS = { success: ['login_success', 'login_2fa_success', 'user_registered', 'user_created', 'email_verified_admin', 'invitation_accepted', 'invitation_sent', '2fa_enabled'], warning: ['role_changed', 'status_changed', '2fa_disabled', 'user_deleted', 'account_self_deleted'], failure: ['login_failed'], }; const STATUS_CASE_SQL = ` CASE WHEN al.action IN (${STATUS_ACTIONS.success.map(() => '?').join(',')}) THEN 'success' WHEN al.action IN (${STATUS_ACTIONS.warning.map(() => '?').join(',')}) THEN 'warning' WHEN al.action IN (${STATUS_ACTIONS.failure.map(() => '?').join(',')}) THEN 'failure' ELSE NULL END `; const STATUS_CASE_PARAMS = [...STATUS_ACTIONS.success, ...STATUS_ACTIONS.warning, ...STATUS_ACTIONS.failure]; // ── GET / — liste paginée avec filtres ──────────────────────────────────── router.get('/', (req, res, next) => { try { purgeOldLogs(); const page = Math.max(1, Number(req.query.page) || 1); const limit = Math.min(Number(req.query.limit) || 50, 200); const offset = (page - 1) * limit; const category = req.query.category || null; const status = req.query.status || null; // 'success' | 'warning' | 'failure' const search = req.query.search || null; // filtre sur email acteur ou cible const dateFrom = req.query.dateFrom || null; const dateTo = req.query.dateTo || null; const userId = req.query.userId ? Number(req.query.userId) : null; // Conditions communes (hors statut) — réutilisées pour les compteurs par statut, // qui doivent rester visibles/à jour même quand un statut est déjà sélectionné. const baseConditions = []; const baseParams = []; if (category) { baseConditions.push('al.category = ?'); baseParams.push(category); } if (userId) { baseConditions.push('(al.actor_id = ? OR al.target_user_id = ?)'); baseParams.push(userId, userId); } if (search) { baseConditions.push('(actor.email LIKE ? OR target.email LIKE ? OR actor.display_name LIKE ? OR target.display_name LIKE ?)'); const like = `%${search}%`; baseParams.push(like, like, like, like); } if (dateFrom) { baseConditions.push('al.created_at >= ?'); baseParams.push(dateFrom); } if (dateTo) { baseConditions.push('al.created_at <= ?'); baseParams.push(dateTo + ' 23:59:59'); } const conditions = [...baseConditions]; const params = [...baseParams]; if (status) { conditions.push(`(${STATUS_CASE_SQL}) = ?`); params.push(...STATUS_CASE_PARAMS, status); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const baseWhere = baseConditions.length ? `WHERE ${baseConditions.join(' AND ')}` : ''; const rows = db.prepare(` SELECT al.id, al.action, al.category, al.details, al.ip_address, al.user_agent, al.created_at, actor.id AS actor_id, actor.email AS actor_email, actor.display_name AS actor_name, target.id AS target_id, target.email AS target_email, target.display_name AS target_name FROM audit_logs al LEFT JOIN users actor ON actor.id = al.actor_id LEFT JOIN users target ON target.id = al.target_user_id ${where} ORDER BY al.created_at DESC LIMIT ? OFFSET ? `).all(...params, limit, offset); const total = db.prepare(` SELECT COUNT(*) AS n FROM audit_logs al LEFT JOIN users actor ON actor.id = al.actor_id LEFT JOIN users target ON target.id = al.target_user_id ${where} `).get(...params).n; // Compteurs par statut, calculés avec les mêmes filtres (catégorie/recherche/dates) // mais SANS le filtre statut lui-même, pour permettre de basculer entre statuts. const statusRows = db.prepare(` SELECT (${STATUS_CASE_SQL}) AS ev_status, COUNT(*) AS n FROM audit_logs al LEFT JOIN users actor ON actor.id = al.actor_id LEFT JOIN users target ON target.id = al.target_user_id ${baseWhere} GROUP BY ev_status `).all(...STATUS_CASE_PARAMS, ...baseParams); const statusCounts = { success: 0, warning: 0, failure: 0 }; for (const r of statusRows) { if (r.ev_status && statusCounts[r.ev_status] !== undefined) statusCounts[r.ev_status] = r.n; } // Parser les details JSON const parsed = rows.map(r => { const details = r.details ? (() => { try { return JSON.parse(r.details); } catch { return r.details; } })() : null; // Repli sur les infos figées dans "details" quand l'utilisateur (acteur et/ou cible) // a depuis été supprimé — son FK passe à NULL (ON DELETE SET NULL) mais l'email/nom // saisis au moment de l'action restent lisibles pour les admins. const fallbackName = details && typeof details === 'object' ? (details.display_name || null) : null; const fallbackEmail = details && typeof details === 'object' ? (details.email || null) : null; return { ...r, details, actor_name: r.actor_name ?? (r.actor_id == null ? fallbackName : null), actor_email: r.actor_email ?? (r.actor_id == null ? fallbackEmail : null), target_name: r.target_name ?? (r.target_id == null ? fallbackName : null), target_email:r.target_email?? (r.target_id == null ? fallbackEmail: null), }; }); res.json({ total, page, limit, rows: parsed, statusCounts }); } catch (e) { next(e); } }); // ── GET /categories — liste des catégories présentes ────────────────────── router.get('/categories', (_req, res, next) => { try { const rows = db.prepare( "SELECT DISTINCT category FROM audit_logs WHERE created_at >= datetime('now', '-30 days') ORDER BY category" ).all(); res.json(rows.map(r => r.category)); } catch (e) { next(e); } }); export default router;