Files
crowdlending-app/frontend/src/pages/admin/AuditLogsSection.jsx
T

385 lines
16 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 lutilisateur)',
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>
);
}