Audit activité utilisateurs

This commit is contained in:
2026-06-15 22:28:15 +02:00
parent f54d352b7f
commit a5b4f3e721
10 changed files with 528 additions and 21 deletions
@@ -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>
);
}