diff --git a/backend/src/routes/auditLogs.js b/backend/src/routes/auditLogs.js index 584597b..e028407 100644 --- a/backend/src/routes/auditLogs.js +++ b/backend/src/routes/auditLogs.js @@ -13,6 +13,24 @@ 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 { @@ -22,37 +40,48 @@ router.get('/', (req, res, next) => { 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; - const conditions = []; - const params = []; + // 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) { - conditions.push('al.category = ?'); - params.push(category); + baseConditions.push('al.category = ?'); + baseParams.push(category); } if (userId) { - conditions.push('(al.actor_id = ? OR al.target_user_id = ?)'); - params.push(userId, userId); + baseConditions.push('(al.actor_id = ? OR al.target_user_id = ?)'); + baseParams.push(userId, userId); } if (search) { - conditions.push('(actor.email LIKE ? OR target.email LIKE ? OR actor.display_name LIKE ? OR target.display_name LIKE ?)'); + baseConditions.push('(actor.email LIKE ? OR target.email LIKE ? OR actor.display_name LIKE ? OR target.display_name LIKE ?)'); const like = `%${search}%`; - params.push(like, like, like, like); + baseParams.push(like, like, like, like); } if (dateFrom) { - conditions.push('al.created_at >= ?'); - params.push(dateFrom); + baseConditions.push('al.created_at >= ?'); + baseParams.push(dateFrom); } if (dateTo) { - conditions.push('al.created_at <= ?'); - params.push(dateTo + ' 23:59:59'); + baseConditions.push('al.created_at <= ?'); + baseParams.push(dateTo + ' 23:59:59'); } - const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; + 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 @@ -85,13 +114,41 @@ router.get('/', (req, res, next) => { ${where} `).get(...params).n; - // Parser les details JSON - const parsed = rows.map(r => ({ - ...r, - details: r.details ? (() => { try { return JSON.parse(r.details); } catch { return r.details; } })() : null, - })); + // 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); - res.json({ total, page, limit, rows: parsed }); + 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); } }); diff --git a/frontend/src/pages/admin/AuditLogsSection.jsx b/frontend/src/pages/admin/AuditLogsSection.jsx index 29eedb3..91482ff 100644 --- a/frontend/src/pages/admin/AuditLogsSection.jsx +++ b/frontend/src/pages/admin/AuditLogsSection.jsx @@ -12,39 +12,59 @@ const CATEGORY_META = { invitation: { label: 'Invitation', color: '#6366f1' }, }; -const ACTION_LABELS = { - login_success: 'Connexion réussie', - login_failed: 'Échec de connexion', - login_2fa_success: 'Connexion 2FA réussie', - user_registered: 'Auto-inscription', - user_created: 'Compte créé (admin)', - user_deleted: 'Compte supprimé', - email_verified_admin:'Email vérifié (admin)', - role_changed: 'Rôle modifié', - status_changed: 'Statut modifié', - '2fa_enabled': '2FA activé', - '2fa_disabled': '2FA désactivé', - invitation_sent: 'Invitation envoyée', - invitation_accepted: 'Invitation acceptée', +// ── Statut succès / avertissement / échec par action ─────────────────────── +// IMPORTANT : garder synchronisé avec STATUS_ACTIONS dans backend/src/routes/auditLogs.js +const ACTION_STATUS = { + login_success: 'success', + login_2fa_success: 'success', + user_registered: 'success', + user_created: 'success', + email_verified_admin: 'success', + invitation_accepted: 'success', + invitation_sent: 'success', + '2fa_enabled': 'success', + + role_changed: 'warning', + status_changed: 'warning', + '2fa_disabled': 'warning', + user_deleted: 'warning', + account_self_deleted: 'warning', + + login_failed: 'failure', }; -// ── Helpers ───────────────────────────────────────────────────────────────── +const STATUS_META = { + success: { label: 'Succès', color: '#16a34a' }, + warning: { label: 'Avertissement', color: '#f59e0b' }, + failure: { label: 'Échec', color: '#dc2626' }, +}; -function fmtDateTime(str) { - if (!str) return '—'; - const d = new Date(str.replace(' ', 'T') + (str.includes('+') ? '' : 'Z')); - return d.toLocaleString('fr-FR', { - day: '2-digit', month: '2-digit', year: 'numeric', - hour: '2-digit', minute: '2-digit', - }); +function StatusIcon({ status, color }) { + const meta = STATUS_META[status]; + if (!meta) return null; + const common = { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: color || meta.color, strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' }; + return ( + + {status === 'success' && ( + + )} + {status === 'warning' && ( + + )} + {status === 'failure' && ( + + )} + + ); } -function CategoryBadge({ cat, active, onClick }) { - const meta = CATEGORY_META[cat] || { label: cat, color: '#6b7280' }; +function StatusBadge({ status, count, active, onClick }) { + const meta = STATUS_META[status]; return ( ); } +const ACTION_LABELS = { + login_success: 'Connexion réussie', + login_failed: 'Échec de connexion', + login_2fa_success: 'Connexion 2FA réussie', + user_registered: 'Auto-inscription', + user_created: 'Compte créé (admin)', + user_deleted: 'Compte supprimé', + account_self_deleted:'Compte supprimé (par l’utilisateur)', + email_verified_admin:'Email vérifié (admin)', + role_changed: 'Rôle modifié', + status_changed: 'Statut modifié', + '2fa_enabled': '2FA activé', + '2fa_disabled': '2FA désactivé', + invitation_sent: 'Invitation envoyée', + invitation_accepted: 'Invitation acceptée', +}; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function fmtPerson(name, email) { + if (name && email) return `${name} (${email})`; + if (email) return email; + if (name) return name; + return '—'; +} + +function fmtDateTime(str) { + if (!str) return '—'; + const d = new Date(str.replace(' ', 'T') + (str.includes('+') ? '' : 'Z')); + return d.toLocaleString('fr-FR', { + day: '2-digit', month: '2-digit', year: 'numeric', + hour: '2-digit', minute: '2-digit', + }); +} + function DetailTooltip({ details }) { if (!details) return null; const entries = typeof details === 'object' @@ -119,9 +184,11 @@ export default function AuditLogsSection() { const [page, setPage] = useState(1); const [loading, setLoading] = useState(true); const [categories, setCats] = useState([]); + const [statusCounts, setStatusCounts] = useState({ success: 0, warning: 0, failure: 0 }); // Filtres const [filterCat, setFilterCat] = useState(''); + const [filterStatus, setFilterStatus] = useState(''); const [filterSearch, setFilterSearch] = useState(''); const [filterFrom, setFilterFrom] = useState(''); const [filterTo, setFilterTo] = useState(''); @@ -133,6 +200,7 @@ export default function AuditLogsSection() { try { const params = { page: p, limit: LIMIT }; if (filterCat) params.category = filterCat; + if (filterStatus) params.status = filterStatus; if (filterSearch) params.search = filterSearch; if (filterFrom) params.dateFrom = filterFrom; if (filterTo) params.dateTo = filterTo; @@ -143,6 +211,7 @@ export default function AuditLogsSection() { ]); setRows(data.rows); setTotal(data.total); + setStatusCounts(data.statusCounts || { success: 0, warning: 0, failure: 0 }); setPage(p); if (!categories.length) setCats(cats); } catch (e) { @@ -150,9 +219,9 @@ export default function AuditLogsSection() { } finally { setLoading(false); } - }, [filterCat, filterSearch, filterFrom, filterTo]); // eslint-disable-line + }, [filterCat, filterStatus, filterSearch, filterFrom, filterTo]); // eslint-disable-line - useEffect(() => { load(1); }, [filterCat, filterFrom, filterTo]); // eslint-disable-line + useEffect(() => { load(1); }, [filterCat, filterStatus, filterFrom, filterTo]); // eslint-disable-line // Recherche texte : debounce 350ms useEffect(() => { @@ -160,9 +229,10 @@ export default function AuditLogsSection() { return () => clearTimeout(t); }, [filterSearch]); // eslint-disable-line - const toggleCat = (cat) => setFilterCat(prev => prev === cat ? '' : cat); + const toggleStatus = (st) => setFilterStatus(prev => prev === st ? '' : st); const allCats = Object.keys(CATEGORY_META); + const allStatuses = Object.keys(STATUS_META); return (
@@ -173,10 +243,25 @@ export default function AuditLogsSection() {
- {/* Filtres catégories */} -
- {allCats.map(cat => ( - + {/* Filtres : catégorie (liste déroulante) + statut (chips avec compteurs) */} +
+ + + {allStatuses.map(st => ( + ))}
@@ -249,9 +334,9 @@ export default function AuditLogsSection() { {rows.map((row, i) => { const meta = CATEGORY_META[row.category] || { label: row.category, color: '#6b7280' }; const label = ACTION_LABELS[row.action] || row.action; - const actor = row.actor_name || row.actor_email || '—'; + const actor = fmtPerson(row.actor_name, row.actor_email); const target = row.target_email && row.target_id !== row.actor_id - ? (row.target_name || row.target_email) + ? fmtPerson(row.target_name, row.target_email) : '—'; return ( @@ -275,6 +360,7 @@ export default function AuditLogsSection() { + {label}