From a5b4f3e721cbced3c91159b7982fa8c4c07b6345 Mon Sep 17 00:00:00 2001 From: Olivier Date: Mon, 15 Jun 2026 22:28:15 +0200 Subject: [PATCH] =?UTF-8?q?Audit=20activit=C3=A9=20utilisateurs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db/index.js | 26 ++ backend/src/routes/admin.js | 7 + backend/src/routes/auditLogs.js | 108 +++++++ backend/src/routes/auth.js | 19 +- backend/src/routes/invitations.js | 3 + backend/src/server.js | 5 +- backend/src/utils/audit.js | 46 +++ frontend/src/pages/Admin.jsx | 20 +- frontend/src/pages/admin/AuditLogsSection.jsx | 298 ++++++++++++++++++ frontend/src/pages/admin/UsersSection.jsx | 17 +- 10 files changed, 528 insertions(+), 21 deletions(-) create mode 100644 backend/src/routes/auditLogs.js create mode 100644 backend/src/utils/audit.js create mode 100644 frontend/src/pages/admin/AuditLogsSection.jsx diff --git a/backend/src/db/index.js b/backend/src/db/index.js index 3f31fcd..bdcc6fe 100644 --- a/backend/src/db/index.js +++ b/backend/src/db/index.js @@ -1832,4 +1832,30 @@ console.log('[DB] Migrations 2FA OK'); } +// ── Table audit_logs ───────────────────────────────────────────────────────── +{ + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='audit_logs'").get(); + if (!tables) { + db.exec(` + CREATE TABLE audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + actor_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + target_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + action TEXT NOT NULL, + category TEXT NOT NULL, + details TEXT, + ip_address TEXT, + user_agent TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + db.exec('CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_audit_logs_target ON audit_logs(target_user_id)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_audit_logs_category ON audit_logs(category)'); + db.exec('CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at)'); + console.log('[DB] Table audit_logs créée'); + } +} + + export default db; diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index bc483e2..bee233e 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -4,6 +4,7 @@ import { z } from 'zod'; import db from '../db/index.js'; import { HttpError } from '../middleware/errorHandler.js'; import { checkStatutsRetard } from '../jobs/autoStatut.js'; +import { audit } from '../utils/audit.js'; // ── Helpers similarité de noms ──────────────────────────────────────────── */ @@ -62,6 +63,7 @@ router.patch('/users/:id/verify-email', (req, res, next) => { const r = db.prepare("UPDATE users SET email_verified=1, updated_at=datetime('now') WHERE id=?").run(targetId); if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable'); db.prepare('UPDATE email_verification_tokens SET used=1 WHERE user_id=? AND used=0').run(targetId); + audit(req, { action: 'email_verified_admin', category: 'account', actorId: req.user.id, targetUserId: targetId }); res.json({ ok: true }); } catch (e) { next(e); } }); @@ -92,6 +94,7 @@ router.post('/users', (req, res, next) => { `INSERT INTO investisseurs (user_id, nom, prenom, type, type_fiscal) VALUES (?, ?, ?, 'famille', 'PP')` ).run(userId, fullName, prenom); + audit(req, { action: 'user_created', category: 'account', actorId: req.user.id, targetUserId: userId, details: { email: body.email, role: body.role, created_by_admin: true } }); res.status(201).json({ id: userId, email: body.email, display_name: body.displayName || null, role: body.role }); } catch (e) { next(e); } }); @@ -111,6 +114,7 @@ router.patch('/users/:id/status', (req, res, next) => { const r = db.prepare("UPDATE users SET status=?, updated_at=datetime('now') WHERE id=?") .run(status, targetId); if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable'); + audit(req, { action: 'status_changed', category: 'status', actorId: req.user.id, targetUserId: targetId, details: { new_status: status } }); res.json({ id: targetId, status }); } catch (e) { next(e); } }); @@ -133,6 +137,7 @@ router.patch('/users/:id/role', (req, res, next) => { const r = db.prepare("UPDATE users SET role=?, updated_at=datetime('now') WHERE id=?") .run(role, targetId); if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable'); + audit(req, { action: 'role_changed', category: 'role', actorId: req.user.id, targetUserId: targetId, details: { new_role: role } }); res.json({ id: targetId, role }); } catch (e) { next(e); } @@ -145,8 +150,10 @@ router.delete('/users/:id', (req, res, next) => { if (targetId === req.user.id) { throw new HttpError(400, 'Vous ne pouvez pas supprimer votre propre compte'); } + const targetUser = db.prepare('SELECT email, display_name FROM users WHERE id = ?').get(targetId); const r = db.prepare('DELETE FROM users WHERE id = ?').run(targetId); if (r.changes === 0) throw new HttpError(404, 'Utilisateur introuvable'); + audit(req, { action: 'user_deleted', category: 'account', actorId: req.user.id, details: { email: targetUser?.email, display_name: targetUser?.display_name } }); res.status(204).end(); } catch (e) { next(e); } }); diff --git a/backend/src/routes/auditLogs.js b/backend/src/routes/auditLogs.js new file mode 100644 index 0000000..584597b --- /dev/null +++ b/backend/src/routes/auditLogs.js @@ -0,0 +1,108 @@ +/** + * /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(); +} + +// ── 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 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 = []; + + if (category) { + conditions.push('al.category = ?'); + params.push(category); + } + if (userId) { + conditions.push('(al.actor_id = ? OR al.target_user_id = ?)'); + params.push(userId, userId); + } + if (search) { + conditions.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); + } + if (dateFrom) { + conditions.push('al.created_at >= ?'); + params.push(dateFrom); + } + if (dateTo) { + conditions.push('al.created_at <= ?'); + params.push(dateTo + ' 23:59:59'); + } + + const where = conditions.length ? `WHERE ${conditions.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; + + // Parser les details JSON + const parsed = rows.map(r => ({ + ...r, + details: r.details ? (() => { try { return JSON.parse(r.details); } catch { return r.details; } })() : null, + })); + + res.json({ total, page, limit, rows: parsed }); + } 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; diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index c6e5bfe..78cdb1e 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -10,6 +10,7 @@ import { createRequire } from 'node:module'; const _require = createRequire(import.meta.url); const QRCode = _require('qrcode'); import { generateSecret as totpGenerateSecret, generateURI as totpGenerateURI, verifySync as totpVerifySync } from 'otplib'; +import { audit } from '../utils/audit.js'; const router = Router(); @@ -82,9 +83,11 @@ router.post('/register', async (req, res, next) => { // Ne pas faire échouer l'inscription si le mail échoue } + audit(req, { action: 'user_registered', category: 'account', targetUserId: userId, details: { email: body.email, role, auto_verified: false } }); return res.status(201).json({ requiresVerification: true, email: body.email }); } + audit(req, { action: 'user_registered', category: 'account', targetUserId: userId, details: { email: body.email, role, auto_verified: true } }); const token = signToken({ sub: userId, email: body.email }); res.status(201).json({ token, @@ -103,10 +106,16 @@ router.post('/login', async (req, res, next) => { const user = db .prepare('SELECT id, email, password_hash, display_name, role, email_verified, totp_enabled, status FROM users WHERE email = ?') .get(body.email); - if (!user) throw new HttpError(401, 'Invalid credentials'); + if (!user) { + audit(req, { action: 'login_failed', category: 'auth', details: { email: body.email, reason: 'user_not_found' } }); + throw new HttpError(401, 'Invalid credentials'); + } const ok = bcrypt.compareSync(body.password, user.password_hash); - if (!ok) throw new HttpError(401, 'Invalid credentials'); + if (!ok) { + audit(req, { action: 'login_failed', category: 'auth', targetUserId: user.id, details: { email: body.email, reason: 'wrong_password' } }); + throw new HttpError(401, 'Invalid credentials'); + } if (user.status === 'deactivated') { return res.status(403).json({ error: 'Ce compte a été désactivé. Contactez un administrateur.', code: 'ACCOUNT_DEACTIVATED' }); @@ -152,6 +161,7 @@ router.post('/login', async (req, res, next) => { }); } + audit(req, { action: 'login_success', category: 'auth', actorId: user.id, targetUserId: user.id, details: { email: user.email } }); const token = signToken({ sub: user.id, email: user.email }); res.json({ token, @@ -405,6 +415,7 @@ router.post('/2fa/confirm-setup', requireAuth, async (req, res, next) => { if (!valid) throw new HttpError(400, 'Code invalide. Réessayez.'); db.prepare("UPDATE users SET totp_enabled=1 WHERE id=?").run(req.user.id); + audit(req, { action: '2fa_enabled', category: '2fa', actorId: req.user.id, targetUserId: req.user.id }); res.json({ ok: true }); } catch (e) { next(e); } }); @@ -423,6 +434,7 @@ router.post('/2fa/disable', requireAuth, async (req, res, next) => { db.prepare("UPDATE users SET totp_enabled=0, totp_secret=NULL WHERE id=?").run(req.user.id); // Supprimer tous les appareils de confiance db.prepare('DELETE FROM two_fa_trusted_devices WHERE user_id=?').run(req.user.id); + audit(req, { action: '2fa_disabled', category: '2fa', actorId: req.user.id, targetUserId: req.user.id }); res.json({ ok: true }); } catch (e) { next(e); } @@ -513,6 +525,7 @@ router.post('/2fa/verify', async (req, res, next) => { db.prepare('INSERT INTO two_fa_trusted_devices (user_id, token, expires_at, user_agent, ip_address) VALUES (?,?,?,?,?)').run(user.id, deviceToken, devExpires, ua, ip); } + audit(req, { action: 'login_2fa_success', category: 'auth', actorId: user.id, targetUserId: user.id, details: { method, trustDevice: !!trustDevice } }); const token = signToken({ sub: user.id, email: user.email }); res.json({ token, @@ -555,7 +568,7 @@ router.delete('/trusted-devices/:id', requireAuth, (req, res, next) => { const id = parseInt(req.params.id, 10); const dev = db.prepare('SELECT id FROM two_fa_trusted_devices WHERE id=? AND user_id=?').get(id, req.user.id); if (!dev) throw new HttpError(404, 'Appareil introuvable.'); - db.prepare('DELETE FROM two_fa_trusted_devices WHERE id=?').run(id); + db.prepare('DELETE FROM two_fa_trusted_devices WHERE id=?').run(id); db.prepare('DELETE FROM two_fa_trusted_devices WHERE id=?').run(id); res.json({ ok: true }); } catch (e) { next(e); } }); diff --git a/backend/src/routes/invitations.js b/backend/src/routes/invitations.js index 3049a3c..61a8a2b 100644 --- a/backend/src/routes/invitations.js +++ b/backend/src/routes/invitations.js @@ -18,6 +18,7 @@ import { z } from 'zod'; import db from '../db/index.js'; import { HttpError } from '../middleware/errorHandler.js'; import { sendMail, buildEmailHtml, getSmtpConfig } from '../utils/mailer.js'; +import { audit } from '../utils/audit.js'; const router = Router(); @@ -103,6 +104,7 @@ router.post('/', async (req, res, next) => { }), }); + audit(req, { action: 'invitation_sent', category: 'invitation', actorId: req.user.id, details: { email, role, expiresAt } }); res.json({ ok: true, email, expiresAt }); } catch (e) { next(e); } }); @@ -179,6 +181,7 @@ router.post('/:token/register', async (req, res, next) => { // Marquer l'invitation comme utilisée db.prepare("UPDATE invitations SET used_at = datetime('now') WHERE id = ?").run(inv.id); + audit(req, { action: 'invitation_accepted', category: 'invitation', targetUserId: pendingUser.id, details: { email: inv.email, role: inv.role } }); res.json({ ok: true, userId: pendingUser.id }); } catch (e) { next(e); } diff --git a/backend/src/server.js b/backend/src/server.js index e047cd0..7ec00a3 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -34,6 +34,7 @@ import { requireAuth, requireAdmin } from './middleware/auth.js'; import { startAutoStatutJob } from './jobs/autoStatut.js'; import adminRouter from './routes/admin.js'; import invitationsRouter from './routes/invitations.js'; +import auditLogsRouter from './routes/auditLogs.js'; import tauxCreditImpotRouter from './routes/tauxCreditImpot.js'; import referentielRouter from './routes/referentiel.js'; import referentielPublicRouter from './routes/referentielPublic.js'; @@ -109,6 +110,7 @@ app.use('/api/comptes', requireAuth, comptesRouter); app.use('/api/preferences', requireAuth, preferencesRouter); app.use('/api/icons', requireAuth, iconsRouter); app.use('/api/admin', requireAuth, requireAdmin, adminRouter); +app.use('/api/admin/audit-logs', requireAuth, requireAdmin, auditLogsRouter); // Invitations : routes admin protégées + routes publiques (register/validate) app.use('/api/admin/invitations', requireAuth, requireAdmin, invitationsRouter); app.use('/api/invitations', invitationsRouter); @@ -125,5 +127,4 @@ app.use(errorHandler); app.listen(PORT, () => { console.log(`Crowdlending API listening on http://localhost:${PORT}`); - startAutoStatutJob(); -}); \ No newline at end of file +}); diff --git a/backend/src/utils/audit.js b/backend/src/utils/audit.js new file mode 100644 index 0000000..e67f3b3 --- /dev/null +++ b/backend/src/utils/audit.js @@ -0,0 +1,46 @@ +/** + * audit.js — Helper d'enregistrement des événements d'audit. + * + * Usage : + * import { audit } from '../utils/audit.js'; + * audit(req, { action: 'login_success', category: 'auth', targetUserId: user.id }); + */ + +import db from '../db/index.js'; + +/** + * Enregistre un événement dans audit_logs. + * + * @param {import('express').Request} req — pour extraire IP + User-Agent + * @param {{ + * action: string, — identifiant court de l'action (ex: 'login_success') + * category: string, — 'auth' | 'account' | 'role' | 'status' | '2fa' | 'invitation' + * actorId?: number, — qui a effectué l'action (null = système/anonyme) + * targetUserId?: number, — utilisateur concerné + * details?: object, — données complémentaires libres + * }} opts + */ +export function audit(req, { action, category, actorId = null, targetUserId = null, details = null }) { + try { + const ip = req?.headers?.['x-forwarded-for']?.split(',')[0]?.trim() + || req?.socket?.remoteAddress + || null; + const ua = req?.headers?.['user-agent'] || null; + + db.prepare(` + INSERT INTO audit_logs (actor_id, target_user_id, action, category, details, ip_address, user_agent) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run( + actorId || null, + targetUserId || null, + action, + category, + details ? JSON.stringify(details) : null, + ip, + ua, + ); + } catch (e) { + // Ne jamais bloquer une requête à cause d'un log + console.error('[AUDIT] Erreur enregistrement:', e.message); + } +} diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 44a382a..fe6b199 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -1,13 +1,15 @@ import { useLocation, useNavigate } from 'react-router-dom'; import { useAuth } from '../context/AuthContext.jsx'; -import UsersSection from './admin/UsersSection.jsx'; -import JobLogsSection from './admin/JobLogsSection.jsx'; -import IconsSection from './admin/IconsSection.jsx'; -import SmtpSection from './admin/SmtpSection.jsx'; +import UsersSection from './admin/UsersSection.jsx'; +import AuditLogsSection from './admin/AuditLogsSection.jsx'; +import JobLogsSection from './admin/JobLogsSection.jsx'; +import IconsSection from './admin/IconsSection.jsx'; +import SmtpSection from './admin/SmtpSection.jsx'; /* ── Icônes nav ───────────────────────────────────────────────── */ function IconUsers() { return ; } function IconActivity() { return ; } +function IconShield() { return ; } function IconImage() { return ; } function IconTax() { return ; } function IconDatabase() { return ; } @@ -17,8 +19,9 @@ const NAV = [ { group: 'Administration de la plateforme', items: [ - { id: 'users', label: 'Utilisateurs', icon: }, - { id: 'job-logs', label: 'Logs des jobs', icon: }, + { id: 'users', label: 'Utilisateurs', icon: }, + { id: 'audit-logs', label: 'Audit', icon: }, + { id: 'job-logs', label: 'Logs des jobs', icon: }, { id: 'icons', label: "Bibliothèque d'icônes", icon: }, ], }, @@ -67,8 +70,9 @@ export default function Admin() { ))}
- {section === 'users' && } - {section === 'job-logs' && } + {section === 'users' && } + {section === 'audit-logs' && } + {section === 'job-logs' && } {section === 'icons' && } {section === 'smtp' && }
diff --git a/frontend/src/pages/admin/AuditLogsSection.jsx b/frontend/src/pages/admin/AuditLogsSection.jsx new file mode 100644 index 0000000..29eedb3 --- /dev/null +++ b/frontend/src/pages/admin/AuditLogsSection.jsx @@ -0,0 +1,298 @@ +import { useState, useEffect, useCallback } from 'react'; +import { api } from '../../api.js'; + +// ── Libellés et couleurs par catégorie ───────────────────────────────────── + +const CATEGORY_META = { + auth: { label: 'Connexion', color: '#3b82f6' }, + account: { label: 'Compte', color: '#8b5cf6' }, + role: { label: 'Rôle', color: '#f59e0b' }, + status: { label: 'Statut', color: '#ef4444' }, + '2fa': { label: '2FA', color: '#10b981' }, + 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', +}; + +// ── Helpers ───────────────────────────────────────────────────────────────── + +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 CategoryBadge({ cat, active, onClick }) { + const meta = CATEGORY_META[cat] || { label: cat, color: '#6b7280' }; + return ( + + ); +} + +function DetailTooltip({ details }) { + if (!details) return null; + const entries = typeof details === 'object' + ? Object.entries(details).filter(([, v]) => v !== null && v !== undefined) + : []; + if (entries.length === 0) return null; + + return ( + + `${k}: ${v}`).join('\n')} + >i + + ); +} + +// ── Pagination ─────────────────────────────────────────────────────────────── + +function Pagination({ page, total, limit, onPage }) { + const pages = Math.max(1, Math.ceil(total / limit)); + if (pages <= 1) return null; + const getPages = () => { + const arr = []; + for (let i = Math.max(1, page - 2); i <= Math.min(pages, page + 2); i++) arr.push(i); + return arr; + }; + return ( +
+ + {page > 3 && <>} + {getPages().map(p => ( + + ))} + {page < pages - 2 && <>} + +
+ ); +} + +// ── Composant principal ────────────────────────────────────────────────────── + +export default function AuditLogsSection() { + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(true); + const [categories, setCats] = useState([]); + + // Filtres + const [filterCat, setFilterCat] = useState(''); + const [filterSearch, setFilterSearch] = useState(''); + const [filterFrom, setFilterFrom] = useState(''); + const [filterTo, setFilterTo] = useState(''); + + const LIMIT = 50; + + const load = useCallback(async (p = 1) => { + setLoading(true); + try { + const params = { page: p, limit: LIMIT }; + if (filterCat) params.category = filterCat; + if (filterSearch) params.search = filterSearch; + if (filterFrom) params.dateFrom = filterFrom; + if (filterTo) params.dateTo = filterTo; + + const [data, cats] = await Promise.all([ + api.get('/admin/audit-logs', params), + categories.length ? Promise.resolve(categories) : api.get('/admin/audit-logs/categories'), + ]); + setRows(data.rows); + setTotal(data.total); + setPage(p); + if (!categories.length) setCats(cats); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }, [filterCat, filterSearch, filterFrom, filterTo]); // eslint-disable-line + + useEffect(() => { load(1); }, [filterCat, filterFrom, filterTo]); // eslint-disable-line + + // Recherche texte : debounce 350ms + useEffect(() => { + const t = setTimeout(() => load(1), 350); + return () => clearTimeout(t); + }, [filterSearch]); // eslint-disable-line + + const toggleCat = (cat) => setFilterCat(prev => prev === cat ? '' : cat); + + const allCats = Object.keys(CATEGORY_META); + + return ( +
+
+

Audit — Activité des comptes

+ + Historique sur 30 jours · {total} événement{total !== 1 ? 's' : ''} + +
+ + {/* Filtres catégories */} +
+ {allCats.map(cat => ( + + ))} +
+ + {/* Barre de recherche + dates */} +
+
+ + + + setFilterSearch(e.target.value)} + style={{ background: 'transparent', border: 'none', outline: 'none', flex: 1, fontSize: 13, color: 'var(--text)' }} + /> + {filterSearch && ( + + )} +
+
+ + setFilterFrom(e.target.value)} + style={{ + padding: '0 8px', height: 32, borderRadius: 8, border: '1px solid var(--border)', + background: 'var(--surface)', color: 'var(--text)', fontSize: 13, + }} + /> + + setFilterTo(e.target.value)} + style={{ + padding: '0 8px', height: 32, borderRadius: 8, border: '1px solid var(--border)', + background: 'var(--surface)', color: 'var(--text)', fontSize: 13, + }} + /> + {(filterFrom || filterTo) && ( + + )} +
+
+ + {/* Tableau */} + {loading ? ( +

Chargement…

+ ) : rows.length === 0 ? ( +
+ Aucun événement trouvé pour ces critères. +
+ ) : ( +
+ + + + {['Date / Heure', 'Catégorie', 'Événement', 'Acteur', 'Concerné'].map(h => ( + + ))} + + + + {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 target = row.target_email && row.target_id !== row.actor_id + ? (row.target_name || row.target_email) + : '—'; + + return ( + + + + + + + + ); + })} + +
+ {h} +
+ {fmtDateTime(row.created_at)} + + + {meta.label} + + + {label} + + + {actor} + + {target} +
+
+ )} + + load(p)} /> +
+ ); +} diff --git a/frontend/src/pages/admin/UsersSection.jsx b/frontend/src/pages/admin/UsersSection.jsx index 8478595..27b0621 100644 --- a/frontend/src/pages/admin/UsersSection.jsx +++ b/frontend/src/pages/admin/UsersSection.jsx @@ -371,9 +371,9 @@ function CreateUserModal({ open, onClose, onCreated }) { // ── Composant principal ──────────────────────────────────────────────────── export default function UsersSection({ currentUserId }) { - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [err, setErr] = useState(null); + const [users, setUsers] = useState([]); + const [initialLoading, setInitialLoading] = useState(true); + const [err, setErr] = useState(null); const [confirmAction, setConfirmAction] = useState(null); const [showCreate, setShowCreate] = useState(false); const [showInvite, setShowInvite] = useState(false); @@ -386,13 +386,14 @@ export default function UsersSection({ currentUserId }) { const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(10); - const load = useCallback(async () => { - try { setLoading(true); setUsers(await api.get('/admin/users')); } + const load = useCallback(async (initial = false) => { + if (initial) setInitialLoading(true); + try { setUsers(await api.get('/admin/users')); } catch (e) { setErr(e.message); } - finally { setLoading(false); } + finally { if (initial) setInitialLoading(false); } }, []); - useEffect(() => { load(); }, [load]); + useEffect(() => { load(true); }, [load]); useEffect(() => { setPage(1); }, [search, filterStatus, filterRole]); useEffect(() => { if (!openMenu) return; @@ -514,7 +515,7 @@ export default function UsersSection({ currentUserId }) { const today = new Date().toISOString().slice(0, 10); - if (loading) return

Chargement…

; + if (initialLoading) return

Chargement…

; if (err) return

{err}

; return (