166 lines
6.7 KiB
JavaScript
166 lines
6.7 KiB
JavaScript
/**
|
|
* /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;
|