Audit activité utilisateurs
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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); }
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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); }
|
||||
});
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>; }
|
||||
function IconActivity() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>; }
|
||||
function IconShield() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>; }
|
||||
function IconImage() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>; }
|
||||
function IconTax() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>; }
|
||||
function IconDatabase() { return <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>; }
|
||||
@@ -17,8 +19,9 @@ const NAV = [
|
||||
{
|
||||
group: 'Administration de la plateforme',
|
||||
items: [
|
||||
{ id: 'users', label: 'Utilisateurs', icon: <IconUsers /> },
|
||||
{ id: 'job-logs', label: 'Logs des jobs', icon: <IconActivity /> },
|
||||
{ id: 'users', label: 'Utilisateurs', icon: <IconUsers /> },
|
||||
{ id: 'audit-logs', label: 'Audit', icon: <IconShield /> },
|
||||
{ id: 'job-logs', label: 'Logs des jobs', icon: <IconActivity /> },
|
||||
{ id: 'icons', label: "Bibliothèque d'icônes", icon: <IconImage /> },
|
||||
],
|
||||
},
|
||||
@@ -67,8 +70,9 @@ export default function Admin() {
|
||||
))}
|
||||
</aside>
|
||||
<div className="account-content">
|
||||
{section === 'users' && <UsersSection currentUserId={user?.id} />}
|
||||
{section === 'job-logs' && <JobLogsSection />}
|
||||
{section === 'users' && <UsersSection currentUserId={user?.id} />}
|
||||
{section === 'audit-logs' && <AuditLogsSection />}
|
||||
{section === 'job-logs' && <JobLogsSection />}
|
||||
{section === 'icons' && <IconsSection />}
|
||||
{section === 'smtp' && <SmtpSection />}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={() => onClick(cat)}
|
||||
style={{
|
||||
padding: '3px 10px',
|
||||
borderRadius: 20,
|
||||
border: `1px solid ${meta.color}`,
|
||||
background: active ? meta.color : 'transparent',
|
||||
color: active ? '#fff' : meta.color,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
transition: 'all .15s',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{meta.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<span style={{ position: 'relative', display: 'inline-block', marginLeft: 6 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||
width: 16, height: 16, borderRadius: '50%', background: 'var(--surface-2)',
|
||||
fontSize: 10, color: 'var(--text-muted)', cursor: 'default',
|
||||
border: '1px solid var(--border)',
|
||||
}}
|
||||
title={entries.map(([k, v]) => `${k}: ${v}`).join('\n')}
|
||||
>i</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div style={{ display: 'flex', gap: 4, justifyContent: 'center', padding: '12px 0' }}>
|
||||
<button className="btn btn-outline btn-sm" onClick={() => onPage(page - 1)} disabled={page <= 1}>‹</button>
|
||||
{page > 3 && <><button className="btn btn-outline btn-sm" onClick={() => onPage(1)}>1</button><span style={{ padding: '0 4px', color: 'var(--text-muted)' }}>…</span></>}
|
||||
{getPages().map(p => (
|
||||
<button
|
||||
key={p}
|
||||
className={`btn btn-sm ${p === page ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => onPage(p)}
|
||||
>{p}</button>
|
||||
))}
|
||||
{page < pages - 2 && <><span style={{ padding: '0 4px', color: 'var(--text-muted)' }}>…</span><button className="btn btn-outline btn-sm" onClick={() => onPage(pages)}>{pages}</button></>}
|
||||
<button className="btn btn-outline btn-sm" onClick={() => onPage(page + 1)} disabled={page >= pages}>›</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<div>
|
||||
<div className="topbar" style={{ marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 18, fontWeight: 600 }}>Audit — Activité des comptes</h2>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 13, color: 'var(--text-muted)' }}>
|
||||
Historique sur 30 jours · {total} événement{total !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filtres catégories */}
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 }}>
|
||||
{allCats.map(cat => (
|
||||
<CategoryBadge key={cat} cat={cat} active={filterCat === cat} onClick={toggleCat} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Barre de recherche + dates */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16, flexWrap: 'wrap' }}>
|
||||
<div className="project-search-wrap" style={{ flex: '1 1 200px', minWidth: 180 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Rechercher un utilisateur…"
|
||||
value={filterSearch}
|
||||
onChange={e => setFilterSearch(e.target.value)}
|
||||
style={{ background: 'transparent', border: 'none', outline: 'none', flex: 1, fontSize: 13, color: 'var(--text)' }}
|
||||
/>
|
||||
{filterSearch && (
|
||||
<button onClick={() => setFilterSearch('')} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '0 2px', lineHeight: 1 }}>×</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Du</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filterFrom}
|
||||
onChange={e => 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,
|
||||
}}
|
||||
/>
|
||||
<label style={{ fontSize: 12, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>au</label>
|
||||
<input
|
||||
type="date"
|
||||
value={filterTo}
|
||||
onChange={e => 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) && (
|
||||
<button className="btn btn-outline btn-sm" onClick={() => { setFilterFrom(''); setFilterTo(''); }}>
|
||||
Effacer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tableau */}
|
||||
{loading ? (
|
||||
<p style={{ color: 'var(--text-muted)', fontSize: 14, padding: '32px 0', textAlign: 'center' }}>Chargement…</p>
|
||||
) : rows.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '48px 0', color: 'var(--text-muted)', fontSize: 14 }}>
|
||||
Aucun événement trouvé pour ces critères.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '2px solid var(--border)' }}>
|
||||
{['Date / Heure', 'Catégorie', 'Événement', 'Acteur', 'Concerné'].map(h => (
|
||||
<th key={h} style={{ textAlign: 'left', padding: '6px 10px', fontWeight: 600, color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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 (
|
||||
<tr
|
||||
key={row.id}
|
||||
style={{
|
||||
borderBottom: '1px solid var(--border)',
|
||||
background: i % 2 === 0 ? 'transparent' : 'rgba(0,0,0,.018)',
|
||||
}}
|
||||
>
|
||||
<td style={{ padding: '7px 10px', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
|
||||
{fmtDateTime(row.created_at)}
|
||||
</td>
|
||||
<td style={{ padding: '7px 10px' }}>
|
||||
<span style={{
|
||||
display: 'inline-block', padding: '2px 8px', borderRadius: 12,
|
||||
fontSize: 11, fontWeight: 600, letterSpacing: '.3px',
|
||||
background: `${meta.color}22`, color: meta.color,
|
||||
}}>
|
||||
{meta.label}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '7px 10px' }}>
|
||||
<span style={{ color: 'var(--text)' }}>{label}</span>
|
||||
<DetailTooltip details={row.details} />
|
||||
</td>
|
||||
<td style={{ padding: '7px 10px', color: 'var(--text)' }}>
|
||||
{actor}
|
||||
</td>
|
||||
<td style={{ padding: '7px 10px', color: 'var(--text-muted)' }}>
|
||||
{target}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Pagination page={page} total={total} limit={LIMIT} onPage={p => load(p)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <p style={{ color: 'var(--text-muted)' }}>Chargement…</p>;
|
||||
if (initialLoading) return <p style={{ color: 'var(--text-muted)' }}>Chargement…</p>;
|
||||
if (err) return <p style={{ color: '#ef4444' }}>{err}</p>;
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user