Améliroation de la piste d'audit

This commit is contained in:
2026-07-03 19:49:14 +02:00
parent e11c767c1c
commit 89190c4561
2 changed files with 196 additions and 53 deletions
+75 -18
View File
@@ -13,6 +13,24 @@ function purgeOldLogs() {
db.prepare("DELETE FROM audit_logs WHERE created_at < datetime('now', '-30 days')").run(); 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 ──────────────────────────────────── // ── GET / — liste paginée avec filtres ────────────────────────────────────
router.get('/', (req, res, next) => { router.get('/', (req, res, next) => {
try { try {
@@ -22,37 +40,48 @@ router.get('/', (req, res, next) => {
const limit = Math.min(Number(req.query.limit) || 50, 200); const limit = Math.min(Number(req.query.limit) || 50, 200);
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
const category = req.query.category || null; 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 search = req.query.search || null; // filtre sur email acteur ou cible
const dateFrom = req.query.dateFrom || null; const dateFrom = req.query.dateFrom || null;
const dateTo = req.query.dateTo || null; const dateTo = req.query.dateTo || null;
const userId = req.query.userId ? Number(req.query.userId) : null; const userId = req.query.userId ? Number(req.query.userId) : null;
const conditions = []; // Conditions communes (hors statut) — réutilisées pour les compteurs par statut,
const params = []; // qui doivent rester visibles/à jour même quand un statut est déjà sélectionné.
const baseConditions = [];
const baseParams = [];
if (category) { if (category) {
conditions.push('al.category = ?'); baseConditions.push('al.category = ?');
params.push(category); baseParams.push(category);
} }
if (userId) { if (userId) {
conditions.push('(al.actor_id = ? OR al.target_user_id = ?)'); baseConditions.push('(al.actor_id = ? OR al.target_user_id = ?)');
params.push(userId, userId); baseParams.push(userId, userId);
} }
if (search) { 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}%`; const like = `%${search}%`;
params.push(like, like, like, like); baseParams.push(like, like, like, like);
} }
if (dateFrom) { if (dateFrom) {
conditions.push('al.created_at >= ?'); baseConditions.push('al.created_at >= ?');
params.push(dateFrom); baseParams.push(dateFrom);
} }
if (dateTo) { if (dateTo) {
conditions.push('al.created_at <= ?'); baseConditions.push('al.created_at <= ?');
params.push(dateTo + ' 23:59:59'); 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 where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const baseWhere = baseConditions.length ? `WHERE ${baseConditions.join(' AND ')}` : '';
const rows = db.prepare(` const rows = db.prepare(`
SELECT SELECT
@@ -85,13 +114,41 @@ router.get('/', (req, res, next) => {
${where} ${where}
`).get(...params).n; `).get(...params).n;
// Parser les details JSON // Compteurs par statut, calculés avec les mêmes filtres (catégorie/recherche/dates)
const parsed = rows.map(r => ({ // mais SANS le filtre statut lui-même, pour permettre de basculer entre statuts.
...r, const statusRows = db.prepare(`
details: r.details ? (() => { try { return JSON.parse(r.details); } catch { return r.details; } })() : null, 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); } } catch (e) { next(e); }
}); });
+119 -33
View File
@@ -12,39 +12,59 @@ const CATEGORY_META = {
invitation: { label: 'Invitation', color: '#6366f1' }, invitation: { label: 'Invitation', color: '#6366f1' },
}; };
const ACTION_LABELS = { // ── Statut succès / avertissement / échec par action ───────────────────────
login_success: 'Connexion réussie', // IMPORTANT : garder synchronisé avec STATUS_ACTIONS dans backend/src/routes/auditLogs.js
login_failed: 'Échec de connexion', const ACTION_STATUS = {
login_2fa_success: 'Connexion 2FA réussie', login_success: 'success',
user_registered: 'Auto-inscription', login_2fa_success: 'success',
user_created: 'Compte créé (admin)', user_registered: 'success',
user_deleted: 'Compte supprimé', user_created: 'success',
email_verified_admin:'Email vérifié (admin)', email_verified_admin: 'success',
role_changed: 'Rôle modifié', invitation_accepted: 'success',
status_changed: 'Statut modifié', invitation_sent: 'success',
'2fa_enabled': '2FA activé', '2fa_enabled': 'success',
'2fa_disabled': '2FA désactivé',
invitation_sent: 'Invitation envoyée', role_changed: 'warning',
invitation_accepted: 'Invitation acceptée', 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) { function StatusIcon({ status, color }) {
if (!str) return '—'; const meta = STATUS_META[status];
const d = new Date(str.replace(' ', 'T') + (str.includes('+') ? '' : 'Z')); if (!meta) return null;
return d.toLocaleString('fr-FR', { const common = { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: color || meta.color, strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' };
day: '2-digit', month: '2-digit', year: 'numeric', return (
hour: '2-digit', minute: '2-digit', <span style={{ display: 'inline-flex', marginRight: 6, verticalAlign: 'middle' }} title={meta.label}>
}); {status === 'success' && (
<svg {...common}><circle cx="12" cy="12" r="10"/><polyline points="16 9 11 14 8 11"/></svg>
)}
{status === 'warning' && (
<svg {...common}><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
)}
{status === 'failure' && (
<svg {...common}><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
)}
</span>
);
} }
function CategoryBadge({ cat, active, onClick }) { function StatusBadge({ status, count, active, onClick }) {
const meta = CATEGORY_META[cat] || { label: cat, color: '#6b7280' }; const meta = STATUS_META[status];
return ( return (
<button <button
onClick={() => onClick(cat)} onClick={() => onClick(status)}
style={{ style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '3px 10px', padding: '3px 10px',
borderRadius: 20, borderRadius: 20,
border: `1px solid ${meta.color}`, border: `1px solid ${meta.color}`,
@@ -57,11 +77,56 @@ function CategoryBadge({ cat, active, onClick }) {
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
}} }}
> >
<StatusIcon status={status} color={active ? '#fff' : meta.color} />
{meta.label} {meta.label}
<span style={{
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
minWidth: 18, height: 18, borderRadius: 9, padding: '0 5px',
fontSize: 11, fontWeight: 700,
background: active ? 'rgba(255,255,255,.25)' : `${meta.color}22`,
color: active ? '#fff' : meta.color,
}}>
{count}
</span>
</button> </button>
); );
} }
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 lutilisateur)',
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 }) { function DetailTooltip({ details }) {
if (!details) return null; if (!details) return null;
const entries = typeof details === 'object' const entries = typeof details === 'object'
@@ -119,9 +184,11 @@ export default function AuditLogsSection() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [categories, setCats] = useState([]); const [categories, setCats] = useState([]);
const [statusCounts, setStatusCounts] = useState({ success: 0, warning: 0, failure: 0 });
// Filtres // Filtres
const [filterCat, setFilterCat] = useState(''); const [filterCat, setFilterCat] = useState('');
const [filterStatus, setFilterStatus] = useState('');
const [filterSearch, setFilterSearch] = useState(''); const [filterSearch, setFilterSearch] = useState('');
const [filterFrom, setFilterFrom] = useState(''); const [filterFrom, setFilterFrom] = useState('');
const [filterTo, setFilterTo] = useState(''); const [filterTo, setFilterTo] = useState('');
@@ -133,6 +200,7 @@ export default function AuditLogsSection() {
try { try {
const params = { page: p, limit: LIMIT }; const params = { page: p, limit: LIMIT };
if (filterCat) params.category = filterCat; if (filterCat) params.category = filterCat;
if (filterStatus) params.status = filterStatus;
if (filterSearch) params.search = filterSearch; if (filterSearch) params.search = filterSearch;
if (filterFrom) params.dateFrom = filterFrom; if (filterFrom) params.dateFrom = filterFrom;
if (filterTo) params.dateTo = filterTo; if (filterTo) params.dateTo = filterTo;
@@ -143,6 +211,7 @@ export default function AuditLogsSection() {
]); ]);
setRows(data.rows); setRows(data.rows);
setTotal(data.total); setTotal(data.total);
setStatusCounts(data.statusCounts || { success: 0, warning: 0, failure: 0 });
setPage(p); setPage(p);
if (!categories.length) setCats(cats); if (!categories.length) setCats(cats);
} catch (e) { } catch (e) {
@@ -150,9 +219,9 @@ export default function AuditLogsSection() {
} finally { } finally {
setLoading(false); 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 // Recherche texte : debounce 350ms
useEffect(() => { useEffect(() => {
@@ -160,9 +229,10 @@ export default function AuditLogsSection() {
return () => clearTimeout(t); return () => clearTimeout(t);
}, [filterSearch]); // eslint-disable-line }, [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 allCats = Object.keys(CATEGORY_META);
const allStatuses = Object.keys(STATUS_META);
return ( return (
<div> <div>
@@ -173,10 +243,25 @@ export default function AuditLogsSection() {
</span> </span>
</div> </div>
{/* Filtres catégories */} {/* Filtres : catégorie (liste déroulante) + statut (chips avec compteurs) */}
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}> <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
<select
value={filterCat}
onChange={e => setFilterCat(e.target.value)}
style={{
width: 'auto', minWidth: 180, flexShrink: 0,
height: 32, padding: '0 8px', borderRadius: 8, border: '1px solid var(--border)',
background: 'var(--surface)', color: 'var(--text)', fontSize: 13,
}}
>
<option value="">Toutes les catégories</option>
{allCats.map(cat => ( {allCats.map(cat => (
<CategoryBadge key={cat} cat={cat} active={filterCat === cat} onClick={toggleCat} /> <option key={cat} value={cat}>{CATEGORY_META[cat]?.label || cat}</option>
))}
</select>
{allStatuses.map(st => (
<StatusBadge key={st} status={st} count={statusCounts[st] ?? 0} active={filterStatus === st} onClick={toggleStatus} />
))} ))}
</div> </div>
@@ -249,9 +334,9 @@ export default function AuditLogsSection() {
{rows.map((row, i) => { {rows.map((row, i) => {
const meta = CATEGORY_META[row.category] || { label: row.category, color: '#6b7280' }; const meta = CATEGORY_META[row.category] || { label: row.category, color: '#6b7280' };
const label = ACTION_LABELS[row.action] || row.action; 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 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 ( return (
@@ -275,6 +360,7 @@ export default function AuditLogsSection() {
</span> </span>
</td> </td>
<td style={{ padding: '7px 10px' }}> <td style={{ padding: '7px 10px' }}>
<StatusIcon status={ACTION_STATUS[row.action]} />
<span style={{ color: 'var(--text)' }}>{label}</span> <span style={{ color: 'var(--text)' }}>{label}</span>
<DetailTooltip details={row.details} /> <DetailTooltip details={row.details} />
</td> </td>