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

299 lines
12 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' },
};
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>
);
}