385 lines
16 KiB
React
385 lines
16 KiB
React
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' },
|
||
};
|
||
|
||
// ── Statut succès / avertissement / échec par action ───────────────────────
|
||
// IMPORTANT : garder synchronisé avec STATUS_ACTIONS dans backend/src/routes/auditLogs.js
|
||
const ACTION_STATUS = {
|
||
login_success: 'success',
|
||
login_2fa_success: 'success',
|
||
user_registered: 'success',
|
||
user_created: 'success',
|
||
email_verified_admin: 'success',
|
||
invitation_accepted: 'success',
|
||
invitation_sent: 'success',
|
||
'2fa_enabled': 'success',
|
||
|
||
role_changed: 'warning',
|
||
status_changed: 'warning',
|
||
'2fa_disabled': 'warning',
|
||
user_deleted: 'warning',
|
||
account_self_deleted: 'warning',
|
||
|
||
login_failed: 'failure',
|
||
};
|
||
|
||
const STATUS_META = {
|
||
success: { label: 'Succès', color: '#16a34a' },
|
||
warning: { label: 'Avertissement', color: '#f59e0b' },
|
||
failure: { label: 'Échec', color: '#dc2626' },
|
||
};
|
||
|
||
function StatusIcon({ status, color }) {
|
||
const meta = STATUS_META[status];
|
||
if (!meta) return null;
|
||
const common = { width: 14, height: 14, viewBox: '0 0 24 24', fill: 'none', stroke: color || meta.color, strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' };
|
||
return (
|
||
<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 StatusBadge({ status, count, active, onClick }) {
|
||
const meta = STATUS_META[status];
|
||
return (
|
||
<button
|
||
onClick={() => onClick(status)}
|
||
style={{
|
||
display: 'inline-flex', alignItems: 'center', gap: 6,
|
||
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',
|
||
}}
|
||
>
|
||
<StatusIcon status={status} color={active ? '#fff' : meta.color} />
|
||
{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>
|
||
);
|
||
}
|
||
|
||
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 l’utilisateur)',
|
||
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 }) {
|
||
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([]);
|
||
const [statusCounts, setStatusCounts] = useState({ success: 0, warning: 0, failure: 0 });
|
||
|
||
// Filtres
|
||
const [filterCat, setFilterCat] = useState('');
|
||
const [filterStatus, setFilterStatus] = 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 (filterStatus) params.status = filterStatus;
|
||
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);
|
||
setStatusCounts(data.statusCounts || { success: 0, warning: 0, failure: 0 });
|
||
setPage(p);
|
||
if (!categories.length) setCats(cats);
|
||
} catch (e) {
|
||
console.error(e);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [filterCat, filterStatus, filterSearch, filterFrom, filterTo]); // eslint-disable-line
|
||
|
||
useEffect(() => { load(1); }, [filterCat, filterStatus, filterFrom, filterTo]); // eslint-disable-line
|
||
|
||
// Recherche texte : debounce 350ms
|
||
useEffect(() => {
|
||
const t = setTimeout(() => load(1), 350);
|
||
return () => clearTimeout(t);
|
||
}, [filterSearch]); // eslint-disable-line
|
||
|
||
const toggleStatus = (st) => setFilterStatus(prev => prev === st ? '' : st);
|
||
|
||
const allCats = Object.keys(CATEGORY_META);
|
||
const allStatuses = Object.keys(STATUS_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égorie (liste déroulante) + statut (chips avec compteurs) */}
|
||
<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 => (
|
||
<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>
|
||
|
||
{/* 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 = fmtPerson(row.actor_name, row.actor_email);
|
||
const target = row.target_email && row.target_id !== row.actor_id
|
||
? fmtPerson(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' }}>
|
||
<StatusIcon status={ACTION_STATUS[row.action]} />
|
||
<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>
|
||
);
|
||
}
|