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:
Chargement…
+ ) : rows.length === 0 ? ( +| + {h} + | + ))} +||||
|---|---|---|---|---|
| + {fmtDateTime(row.created_at)} + | ++ + {meta.label} + + | +
+ {label}
+ |
+ + {actor} + | ++ {target} + | +
Chargement…
; + if (initialLoading) returnChargement…
; if (err) return{err}
; return (