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

830 lines
44 KiB
React
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, useRef } from 'react';
import * as XLSX from 'xlsx';
import { api } from '../../api.js';
import ConfirmModal from '../../components/ConfirmModal.jsx';
import Modal from '../../components/Modal.jsx';
import ResultBanner from '../../components/ResultBanner.jsx';
import PasswordStrength from '../../components/PasswordStrength.jsx';
import PasswordInput from '../../components/PasswordInput.jsx';
import { fmt, Badge, UserStatusBadge } from './adminHelpers.jsx';
import { fmtOctets } from '../../utils/format.js';
// ── Helpers ────────────────────────────────────────────────────────────────
function computedStatus(u) {
if (u.status === 'deactivated') return 'deactivated';
if (u.status === 'locked') return 'locked';
if (!u.email_verified) return 'pending';
return 'active';
}
const STATUS_OPTIONS = [
{ value: 'all', label: 'Tous' },
{ value: 'active', label: 'Actif' },
{ value: 'pending', label: 'En attente' },
{ value: 'deactivated', label: 'Désactivé' },
{ value: 'locked', label: 'Verrouillé' },
];
const ROLE_OPTIONS = [
{ value: 'all', label: 'Tous' },
{ value: 'user', label: 'Utilisateur' },
{ value: 'admin', label: 'Admin' },
];
const AVATAR_COLORS = [
['#dbeafe','#1d4ed8'], ['#fce7f3','#be185d'], ['#d1fae5','#065f46'],
['#fef3c7','#92400e'], ['#ede9fe','#5b21b6'], ['#fee2e2','#991b1b'],
['#cffafe','#0e7490'], ['#dcfce7','#166534'],
];
function avatarColor(name) {
const code = [...(name || '?')].reduce((a, c) => a + c.charCodeAt(0), 0);
return AVATAR_COLORS[code % AVATAR_COLORS.length];
}
function initials(u) {
const n = u.display_name || u.email;
const parts = n.trim().split(/\s+/);
return parts.length >= 2
? (parts[0][0] + parts[1][0]).toUpperCase()
: n.slice(0, 2).toUpperCase();
}
// ── Icônes SVG ─────────────────────────────────────────────────────────────
const IcoEdit = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>;
const IcoCheck = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 6 9 17 4 12"/></svg>;
const IcoMail = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>;
const IcoPause = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/></svg>;
const IcoPlay = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"/></svg>;
const IcoTrash = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>;
const IcoDots = () => <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="5" r="1" fill="currentColor"/><circle cx="12" cy="12" r="1" fill="currentColor"/><circle cx="12" cy="19" r="1" fill="currentColor"/></svg>;
const IcoSearch = () => <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>;
const IcoSparkle = () => <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/></svg>;
// Interrupteur compact — accès individuel à l'Assistant IA (même style visuel
// que le Toggle de AdminIA.jsx, dupliqué ici car non partagé entre les deux
// pages). Un clic déclenche toggleAiAccess(u), qui passe par une confirmation.
function AiToggle({ checked, onClick, disabled, title }) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
title={title}
style={{
display: 'inline-flex', alignItems: 'center', width: 36, height: 20, borderRadius: 10,
background: checked ? 'var(--primary)' : 'var(--border)', position: 'relative',
border: 'none', cursor: disabled ? 'default' : 'pointer', padding: 0, flexShrink: 0,
transition: 'background 0.2s', opacity: disabled ? 0.5 : 1,
}}
>
<span style={{
position: 'absolute', top: 2, left: checked ? 18 : 2, width: 16, height: 16,
borderRadius: '50%', background: '#fff', transition: 'left 0.2s',
boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
}} />
</button>
);
}
function MenuBtn({ onClick, icon, label, danger = false }) {
return (
<button
style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%', padding: '8px 14px', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'var(--fs-sm)', color: danger ? 'var(--danger)' : 'var(--text)', textAlign: 'left' }}
onMouseEnter={e => e.currentTarget.style.background = 'var(--surface-2)'}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
onClick={onClick}
>
{icon}{label}
</button>
);
}
// ── Export ─────────────────────────────────────────────────────────────────
const STATUS_FR = { active: 'Actif', pending: 'En attente', deactivated: 'Désactivé', locked: 'Verrouillé' };
function toRows(users) {
return users.map(u => ({
ID: u.id, Nom: u.display_name || '', Email: u.email,
Statut: STATUS_FR[computedStatus(u)] || '',
Rôle: u.role === 'admin' ? 'Admin' : 'Utilisateur',
'Assistant IA': u.ai_enabled ? 'Activé' : 'Désactivé',
'2FA': u.totp_enabled ? 'Oui' : 'Non',
'Inscrit le': u.created_at ? u.created_at.replace('T', ' ').slice(0, 16) : '',
Plateformes: u.nb_plateformes ?? 0,
Investissements: u.nb_investissements ?? 0,
Documents: u.nb_documents ?? 0,
'Taille documents': fmtOctets(u.taille_documents ?? 0),
}));
}
function dlBlob(content, filename, type) {
const blob = new Blob([content], { type });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
function toCSV(users) {
const headers = ['ID', 'Nom', 'Email', 'Statut', 'Rôle', 'Assistant IA', '2FA', 'Inscrit le', 'Plateformes', 'Investissements', 'Documents', 'Taille documents'];
const rows = toRows(users).map(r => Object.values(r).map(v => `"${String(v).replace(/"/g,'""')}"`).join(','));
return '' + [headers.map(h => `"${h}"`).join(','), ...rows].join('\n');
}
function toXLSX(users) {
const ws = XLSX.utils.json_to_sheet(toRows(users));
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Utilisateurs');
return XLSX.write(wb, { type: 'array', bookType: 'xlsx' });
}
function toJSON(users) { return JSON.stringify(toRows(users), null, 2); }
function ExportDropdown({ onCSV, onXLSX, onJSON }) {
const [open, setOpen] = useState(false);
const ref = useRef(null);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
const choose = fn => { setOpen(false); fn(); };
return (
<div ref={ref} style={{ position: 'relative' }}>
<button className="btn btn-outline btn-sm" onClick={() => setOpen(o => !o)}
style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Exporter
</button>
{open && (
<div className="export-dropdown" role="menu">
<button role="menuitem" onClick={() => choose(onCSV)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/></svg>
<span><strong>Format CSV</strong><small>Compatible Excel, LibreOffice</small></span>
</button>
<button role="menuitem" onClick={() => choose(onXLSX)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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"/><path d="M9 13l2 2 4-4"/></svg>
<span><strong>Format Excel</strong><small>Fichier .xlsx Microsoft Excel</small></span>
</button>
<button role="menuitem" onClick={() => choose(onJSON)}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><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"/><path d="M8 13h1.5a1 1 0 0 1 1 1v1a1 1 0 0 0 1 1 1 1 0 0 0-1 1v1a1 1 0 0 1-1 1H8"/><path d="M16 13h-1.5a1 1 0 0 0-1 1v1a1 1 0 0 1-1 1 1 1 0 0 1 1 1v1a1 1 0 0 0 1 1H16"/></svg>
<span><strong>Format JSON</strong><small>Réimportable, structuré</small></span>
</button>
</div>
)}
</div>
);
}
// ── Pagination ─────────────────────────────────────────────────────────────
const PAGE_SIZES = [10, 15, 25, 50];
function AdminPagination({ page, setPage, pageSize, setPageSize, total }) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (total === 0) return null;
const delta = 2;
const pages = [];
for (let i = Math.max(1, page - delta); i <= Math.min(totalPages, page + delta); i++) pages.push(i);
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0 0', gap: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>
Lignes par page
<select value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}
style={{ fontSize: 'var(--fs-xs)', padding: '3px 6px', border: '1px solid var(--border)', borderRadius: 5, background: 'var(--surface)', color: 'var(--text)', cursor: 'pointer' }}>
{PAGE_SIZES.map(n => <option key={n} value={n}>{n}</option>)}
</select>
</label>
<span style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>Page {page} sur {totalPages}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 3, flexShrink: 0 }}>
<PBtn onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1}>‹</PBtn>
{pages[0] > 1 && <><PBtn onClick={() => setPage(1)}>1</PBtn>{pages[0] > 2 && <Ellipsis />}</>}
{pages.map(n => <PBtn key={n} onClick={() => setPage(n)} active={n === page}>{n}</PBtn>)}
{pages[pages.length - 1] < totalPages && <>{pages[pages.length - 1] < totalPages - 1 && <Ellipsis />}<PBtn onClick={() => setPage(totalPages)}>{totalPages}</PBtn></>}
<PBtn onClick={() => setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages}>›</PBtn>
</div>
</div>
);
}
const Ellipsis = () => <span style={{ padding: '0 2px', color: 'var(--text-muted)', fontSize: 12 }}>…</span>;
function PBtn({ onClick, disabled, active, children }) {
return (
<button onClick={onClick} disabled={disabled} style={{
minWidth: 30, height: 30, padding: '0 6px', border: active ? '1.5px solid var(--primary)' : '1px solid var(--border)',
borderRadius: 6, background: active ? 'var(--primary-bg, #eff6ff)' : 'var(--surface)',
color: active ? 'var(--primary)' : 'var(--text)', fontSize: 13, fontWeight: active ? 600 : 400,
cursor: disabled ? 'default' : 'pointer', opacity: disabled ? 0.4 : 1,
}}
onMouseEnter={e => { if (!disabled && !active) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => { if (!active) e.currentTarget.style.background = 'var(--surface)'; }}
>{children}</button>
);
}
// ── Composant principal ────────────────────────────────────────────────────
// ── Modale invitation ──────────────────────────────────────────────────────
function InviteUserModal({ open, onClose, onSent }) {
const [form, setForm] = useState({ email: '', role: 'user' });
const [loading, setLoading] = useState(false);
const [err, setErr] = useState(null);
const [sent, setSent] = useState(false);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const reset = () => { setForm({ email: '', role: 'user' }); setErr(null); setSent(false); };
const handleClose = () => { reset(); onClose(); };
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true); setErr(null);
try {
await api.post('/admin/invitations', { email: form.email, role: form.role });
setSent(true);
onSent?.();
} catch (e) { setErr(e.message); }
finally { setLoading(false); }
};
return (
<Modal open={open} title="Inviter un utilisateur" onClose={handleClose} width={440}
footer={sent ? (
<button className="btn btn-primary" onClick={handleClose}>Fermer</button>
) : (
<>
<button type="button" className="btn btn-outline" onClick={handleClose} disabled={loading}>Annuler</button>
<button type="submit" form="invite-user-form" className="btn btn-primary" disabled={loading}>
{loading ? 'Envoi…' : 'Envoyer l\'invitation'}
</button>
</>
)}
>
{sent ? (
<div style={{ textAlign: 'center', padding: '12px 0' }}>
<div style={{ width: 52, height: 52, borderRadius: '50%', background: '#f0fdf4', display: 'flex', alignItems: 'center', justifyContent: 'center', margin: '0 auto 16px' }}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#16a34a" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>
</svg>
</div>
<p style={{ margin: '0 0 6px', fontWeight: 600, color: 'var(--text)' }}>Invitation envoyée !</p>
<p style={{ margin: 0, fontSize: 13, color: 'var(--text-muted)' }}>
Un email a été envoyé à <strong>{form.email}</strong>.<br/>Le lien est valable 7 jours.
</p>
</div>
) : (
<>
{err && <div className="error" style={{ marginBottom: 14 }}>{err}</div>}
<form id="invite-user-form" onSubmit={handleSubmit} autoComplete="off" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label>Email *</label>
<input type="email" required autoComplete="off" placeholder="utilisateur@exemple.com"
value={form.email} onChange={e => set('email', e.target.value)} />
</div>
<div>
<label>Rôle</label>
<select value={form.role} onChange={e => set('role', e.target.value)}>
<option value="user">Utilisateur</option>
<option value="admin">Administrateur</option>
</select>
</div>
<p style={{ margin: 0, fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.5 }}>
L'invité recevra un email avec un lien sécurisé pour créer son compte. Son adresse email sera pré-remplie et non modifiable.
</p>
</form>
</>
)}
</Modal>
);
}
// ── Modale renvoi invitation ───────────────────────────────────────────────
function ResendInviteModal({ user, onClose, onSuccess, onError }) {
const [loading, setLoading] = useState(false);
const [err, setErr] = useState(null);
const handleClose = () => { setErr(null); onClose(); };
const handleSend = async () => {
setLoading(true); setErr(null);
try {
await api.post('/admin/invitations', { email: user.email, role: user.role });
// Ferme d'abord, puis notifie le parent — évite le re-render concurrent
handleClose();
onSuccess?.(user.email);
} catch (e) {
setErr(e.message);
} finally { setLoading(false); }
};
const identity = user?.display_name ? `${user.display_name} (${user.email})` : user?.email;
return (
<Modal open={!!user} title="Renvoyer l'invitation" onClose={handleClose} width={420}
footer={
<>
<button className="btn btn-outline" onClick={handleClose} disabled={loading}>Annuler</button>
<button className="btn btn-primary" onClick={handleSend} disabled={loading}>
{loading ? 'Envoi…' : 'Renvoyer'}
</button>
</>
}
>
{err && <div className="error" style={{ marginBottom: 14 }}>{err}</div>}
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: 14, lineHeight: 1.6 }}>
Renvoyer un nouveau lien d'invitation à <strong style={{ color: 'var(--text)' }}>{identity}</strong> ?
<br/>L'ancien lien sera révoqué et remplacé par un nouveau valable 7 jours.
</p>
</Modal>
);
}
// ── Modale création utilisateur ────────────────────────────────────────────
function CreateUserModal({ open, onClose, onCreated }) {
const [minPasswordLength, setMinPasswordLength] = useState(8);
useEffect(() => {
fetch('/api/app-info').then(r => r.json()).then(d => {
if (d.minPasswordLength) setMinPasswordLength(d.minPasswordLength);
}).catch(() => {});
}, []);
const [form, setForm] = useState({ email: '', password: '', displayName: '', role: 'user' });
const [loading, setLoading] = useState(false);
const [err, setErr] = useState(null);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const reset = () => { setForm({ email: '', password: '', displayName: '', role: 'user' }); setErr(null); };
const handleClose = () => { reset(); onClose(); };
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true); setErr(null);
try {
const created = await api.post('/admin/users', {
email: form.email,
password: form.password,
displayName: form.displayName || undefined,
role: form.role,
});
reset();
onCreated?.(created);
onClose();
} catch (e) { setErr(e.message); }
finally { setLoading(false); }
};
return (
<Modal open={open} title="Créer un utilisateur" onClose={handleClose} width={460}
footer={<>
<button type="button" className="btn btn-outline" onClick={handleClose} disabled={loading}>Annuler</button>
<button type="submit" form="create-user-form" className="btn btn-primary" disabled={loading}>
{loading ? 'Création…' : 'Créer le compte'}
</button>
</>}
>
{err && <div className="error" style={{ marginBottom: 14 }}>{err}</div>}
<form id="create-user-form" onSubmit={handleSubmit} autoComplete="off" style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<div>
<label>Nom affiché</label>
<input autoComplete="off" value={form.displayName} onChange={e => set('displayName', e.target.value)} placeholder="Prénom Nom" />
</div>
<div>
<label>Email *</label>
<input type="email" required autoComplete="off" value={form.email} onChange={e => set('email', e.target.value)} placeholder="utilisateur@exemple.com" />
</div>
<div>
<label>Mot de passe *</label>
<PasswordInput required minLength={minPasswordLength} autoComplete="new-password" value={form.password} onChange={e => set('password', e.target.value)} placeholder={`${minPasswordLength} caractères minimum`} />
<PasswordStrength password={form.password} minLength={minPasswordLength} />
</div>
<div>
<label>Rôle</label>
<select value={form.role} onChange={e => set('role', e.target.value)}>
<option value="user">Utilisateur</option>
<option value="admin">Administrateur</option>
</select>
</div>
</form>
</Modal>
);
}
// ── Composant principal ────────────────────────────────────────────────────
export default function UsersSection({ currentUserId }) {
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);
const [resendTarget, setResendTarget] = useState(null);
const [banner, setBanner] = useState(null); // { ok, msg }
const [search, setSearch] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [filterRole, setFilterRole] = useState('all');
const [openMenu, setOpenMenu] = useState(null);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
// null tant que non charge (voir l'effet ci-dessous) : l'interrupteur
// individuel de chaque utilisateur n'a d'effet que si l'IA est activee
// globalement ET qu'un fournisseur actif est configure (seule source de
// verite : aiAccess.js cote backend).
const [aiConfig, setAiConfig] = useState(null);
const load = useCallback(async (initial = false) => {
if (initial) setInitialLoading(true);
try { setUsers(await api.get('/admin/users')); }
catch (e) { setErr(e.message); }
finally { if (initial) setInitialLoading(false); }
}, []);
useEffect(() => { load(true); }, [load]);
useEffect(() => {
let cancelled = false;
Promise.all([api.get('/admin/ai/config'), api.get('/admin/ai/providers')])
.then(([cfg, provs]) => {
if (cancelled) return;
setAiConfig({ actif: !!cfg.actif, hasActiveProvider: provs.some(p => p.actif) });
})
.catch(() => { if (!cancelled) setAiConfig({ actif: false, hasActiveProvider: false }); });
return () => { cancelled = true; };
}, []);
useEffect(() => { setPage(1); }, [search, filterStatus, filterRole]);
useEffect(() => {
if (!openMenu) return;
const close = () => setOpenMenu(null);
window.addEventListener('scroll', close, true);
return () => window.removeEventListener('scroll', close, true);
}, [openMenu]);
// ── Filtrage ──────────────────────────────────────────────────────────
const filtered = users.filter(u => {
const q = search.toLowerCase();
if (q && !u.email.toLowerCase().includes(q) && !(u.display_name || '').toLowerCase().includes(q)) return false;
if (filterStatus !== 'all' && computedStatus(u) !== filterStatus) return false;
if (filterRole !== 'all' && u.role !== filterRole) return false;
return true;
});
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
const safePage = Math.min(page, totalPages);
const paged = filtered.slice((safePage - 1) * pageSize, safePage * pageSize);
// true tant que aiConfig n'est pas encore charge : le vrai garde-fou reste
// cote serveur (getAiAccessStatus), ceci n'est qu'une aide visuelle.
const aiAvailable = !aiConfig || (aiConfig.actif && aiConfig.hasActiveProvider);
const aiUnavailableReason = !aiConfig
? ''
: !aiConfig.actif
? "L'Assistant IA est desactive globalement. Activez-le dans Assistant IA > Configuration avant d'accorder l'acces a un utilisateur."
: !aiConfig.hasActiveProvider
? "Aucun fournisseur IA actif n'est configure. Configurez-en un dans Assistant IA > Fournisseurs avant d'accorder l'acces a un utilisateur."
: '';
// ── Actions ───────────────────────────────────────────────────────────
const confirm = (title, message, onConfirm, confirmLabel) =>
setConfirmAction({ title, message, onConfirm, confirmLabel });
const toggleRole = u => {
const newRole = u.role === 'admin' ? 'user' : 'admin';
confirm('Changer le rôle',
`Changer le rôle de ${u.display_name || u.email} → ${newRole === 'admin' ? 'Administrateur' : 'Utilisateur'} ?`,
async () => { await api.patch(`/admin/users/${u.id}/role`, { role: newRole }); load(); }, 'Confirmer');
};
const verifyEmail = u => confirm("Vérifier l'email",
`Marquer l'email de ${u.display_name || u.email} comme vérifié ?`,
async () => { await api.patch(`/admin/users/${u.id}/verify-email`, {}); load(); }, 'Confirmer');
const resendInvitation = u => setResendTarget(u);
const resendVerifEmail = async u => {
try {
await api.post('/auth/resend-verification', { email: u.email });
setBanner({ ok: true, msg: `Email de vérification renvoyé à ${u.email}.` });
} catch (e) { setBanner({ ok: false, msg: e.message }); }
};
const setStatus = (u, status) => {
const labels = { deactivated: 'Désactiver', active: 'Réactiver', locked: 'Verrouiller' };
confirm(`${labels[status]} le compte`, `${labels[status]} le compte de ${u.display_name || u.email} ?`,
async () => { await api.patch(`/admin/users/${u.id}/status`, { status }); load(); }, labels[status]);
};
const toggleAiAccess = u => {
const enable = !u.ai_enabled;
confirm(
enable ? "Activer l'accès IA" : "Désactiver l'accès IA",
`${enable ? 'Activer' : 'Désactiver'} l'accès à l'Assistant IA pour ${u.display_name || u.email} ?`,
async () => { await api.patch(`/admin/users/${u.id}/ai-access`, { aiEnabled: enable }); load(); },
'Confirmer',
);
};
const deleteUser = u => {
const identity = u.display_name ? `${u.display_name} (${u.email})` : u.email;
confirm(
"Supprimer l'utilisateur",
`Supprimer définitivement le compte de ${identity} ? Cette action est irréversible et effacera toutes ses données.`,
async () => { await api.del(`/admin/users/${u.id}`); load(); }
);
};
const openMenuFor = (e, u) => {
e.stopPropagation();
const rect = e.currentTarget.getBoundingClientRect();
setOpenMenu({ user: u, x: rect.right, y: rect.bottom });
};
// ── Pill dropdown custom ───────────────────────────────────────────────
const PillDropdown = ({ label, value, options, onChange }) => {
const [open, setOpen] = useState(false);
const ref = useRef(null);
const selected = options.find(o => o.value === value);
useEffect(() => {
if (!open) return;
const h = e => { if (!ref.current?.contains(e.target)) setOpen(false); };
document.addEventListener('mousedown', h);
return () => document.removeEventListener('mousedown', h);
}, [open]);
return (
<div ref={ref} style={{ position: 'relative' }}>
<button onClick={() => setOpen(o => !o)} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '5px 10px', borderRadius: 20,
border: `1px solid ${open ? 'var(--primary)' : 'var(--border)'}`,
background: 'var(--surface)', cursor: 'pointer', fontSize: 'var(--fs-xs)', whiteSpace: 'nowrap',
}}>
<span style={{ color: 'var(--text-muted)' }}>{label} :</span>
<span style={{ color: 'var(--text)', fontWeight: 500 }}>{selected?.label}</span>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"
style={{ color: 'var(--text-muted)', marginLeft: 2, transform: open ? 'rotate(180deg)' : 'none', transition: 'transform .15s' }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{open && (
<div style={{
position: 'absolute', top: 'calc(100% + 6px)', left: 0, zIndex: 200,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 16px rgba(0,0,0,.12)',
padding: '4px 0', minWidth: 160,
}}>
{options.map(o => (
<button key={o.value} onClick={() => { onChange(o.value); setOpen(false); }} style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
width: '100%', padding: '7px 14px', background: 'none', border: 'none',
cursor: 'pointer', fontSize: 'var(--fs-sm)', textAlign: 'left',
color: o.value === value ? 'var(--primary)' : 'var(--text)',
fontWeight: o.value === value ? 500 : 400,
}}
onMouseEnter={e => { if (o.value !== value) e.currentTarget.style.background = 'var(--surface-2)'; }}
onMouseLeave={e => e.currentTarget.style.background = 'none'}
>
{o.label}
{o.value === value && (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"/>
</svg>
)}
</button>
))}
</div>
)}
</div>
);
};
const today = new Date().toISOString().slice(0, 10);
if (initialLoading) return <p style={{ color: 'var(--text-muted)' }}>Chargement…</p>;
if (err) return <p style={{ color: '#ef4444' }}>{err}</p>;
return (
<>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{/* Bannière succès/erreur */}
{banner && (
<ResultBanner
result={banner}
onDismiss={() => setBanner(null)}
style={{ margin: '12px 20px 0', borderRadius: 8 }}
/>
)}
{/* En-tête */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '20px 20px 14px', gap: 16, borderBottom: '1px solid var(--border)', flexWrap: 'wrap' }}>
<div>
<h3 style={{ margin: '0 0 3px' }}>Comptes utilisateurs</h3>
<p style={{ margin: 0, fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
Gérez les comptes et les accès des membres de l'application.
</p>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div className="project-search-wrap" style={{ minWidth: 200 }}>
<IcoSearch />
<input className="project-search-input" type="search" placeholder="Rechercher…"
value={search} onChange={e => setSearch(e.target.value)} />
{search && (
<button className="project-search-clear" onClick={() => setSearch('')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
)}
</div>
<ExportDropdown
onCSV={() => dlBlob(toCSV(filtered), `utilisateurs_${today}.csv`, 'text/csv;charset=utf-8')}
onXLSX={() => dlBlob(toXLSX(filtered), `utilisateurs_${today}.xlsx`, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')}
onJSON={() => dlBlob(toJSON(filtered), `utilisateurs_${today}.json`, 'application/json')}
/>
<button className="btn btn-outline btn-sm" onClick={() => setShowInvite(true)}
style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
Inviter
</button>
<button className="btn btn-primary btn-sm" onClick={() => setShowCreate(true)}
style={{ display: 'flex', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/></svg>
Ajouter un utilisateur
</button>
</div>
</div>
{/* Filtres pills */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '12px 20px', borderBottom: '1px solid var(--border)' }}>
<PillDropdown label="Rôle" value={filterRole} options={ROLE_OPTIONS} onChange={setFilterRole} />
<PillDropdown label="Statut" value={filterStatus} options={STATUS_OPTIONS} onChange={setFilterStatus} />
<span style={{ marginLeft: 'auto', fontSize: 'var(--fs-xs)', color: 'var(--text-muted)' }}>
{filtered.length !== users.length
? `${filtered.length} / ${users.length} utilisateur${users.length !== 1 ? 's' : ''}`
: `${users.length} utilisateur${users.length !== 1 ? 's' : ''}`
}
</span>
{(filterRole !== 'all' || filterStatus !== 'all' || search) && (
<button onClick={() => { setFilterRole('all'); setFilterStatus('all'); setSearch(''); }}
style={{ fontSize: 'var(--fs-xs)', color: 'var(--text-muted)', background: 'none', border: 'none', cursor: 'pointer', padding: '4px 6px', borderRadius: 4 }}
onMouseEnter={e => e.currentTarget.style.color = 'var(--danger)'}
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-muted)'}
>Réinitialiser</button>
)}
</div>
{aiConfig && !aiAvailable && (
<div style={{
display: 'flex', alignItems: 'center', gap: 8, margin: '0 20px 12px',
padding: '8px 14px', borderRadius: 8, fontSize: 12.5,
background: 'var(--surface-2)', color: 'var(--text-muted)', border: '1px solid var(--border)',
}}>
<IcoSparkle />
{aiUnavailableReason}
</div>
)}
{/* Tableau */}
{filtered.length === 0 ? (
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '40px 20px' }}>
Aucun utilisateur ne correspond aux filtres.
</p>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ margin: 0 }}>
<thead>
<tr>
<th style={{ paddingLeft: 20 }}>Utilisateur</th>
<th>Statut</th>
<th>Rôle</th>
<th>Assistant IA</th>
<th>2FA</th>
<th>Inscrit le</th>
<th style={{ textAlign: 'right' }}>Nb de plateformes</th>
<th style={{ textAlign: 'right' }}>Nb d'investissements</th>
<th style={{ textAlign: 'right' }}>Nb de documents</th>
<th style={{ textAlign: 'right' }}>Taille des documents</th>
<th style={{ width: 48, paddingRight: 20 }}></th>
</tr>
</thead>
<tbody>
{paged.map(u => {
const [bg, fg] = avatarColor(u.display_name || u.email);
return (
<tr key={u.id} style={{ height: 62 }}>
<td style={{ paddingLeft: 20 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<div style={{
width: 38, height: 38, borderRadius: '50%', flexShrink: 0,
background: bg, color: fg,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 700, letterSpacing: '.03em',
}}>
{initials(u)}
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 'var(--fs-sm)', lineHeight: 1.3 }}>
{u.display_name || <em style={{ color: 'var(--text-muted)', fontStyle: 'normal' }}>{u.email.split('@')[0]}</em>}
{u.id === currentUserId && <span style={{ marginLeft: 6, fontSize: 10, color: 'var(--text-muted)', fontWeight: 400 }}>(vous)</span>}
</div>
<div style={{ fontSize: 11, color: 'var(--text-muted)', lineHeight: 1.3 }}>{u.email}</div>
</div>
</div>
</td>
<td><UserStatusBadge status={u.status || 'active'} emailVerified={u.email_verified} /></td>
<td><Badge role={u.role} /></td>
<td>
<AiToggle
checked={!!u.ai_enabled}
onClick={() => toggleAiAccess(u)}
disabled={!aiAvailable}
title={!aiAvailable ? aiUnavailableReason : (u.ai_enabled ? "Désactiver l'accès à l'Assistant IA" : "Activer l'accès à l'Assistant IA")}
/>
</td>
<td>
{u.totp_enabled
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: '#eff6ff', color: '#1d4ed8', border: '1px solid #bfdbfe' }}>🔐 Actif</span>
: <span style={{ color: 'var(--text-muted)' }}>—</span>
}
</td>
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td>
<td style={{ textAlign: 'right' }}>{u.nb_plateformes ?? 0}</td>
<td style={{ textAlign: 'right' }}>{u.nb_investissements ?? 0}</td>
<td style={{ textAlign: 'right' }}>{u.nb_documents ?? 0}</td>
<td style={{ textAlign: 'right', color: 'var(--text-muted)', fontSize: 12 }}>{fmtOctets(u.taille_documents ?? 0)}</td>
<td style={{ paddingRight: 20 }}>
<button onClick={e => openMenuFor(e, u)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', padding: '4px 6px', borderRadius: 4, display: 'flex', alignItems: 'center' }}
onMouseEnter={e => { e.currentTarget.style.background = 'var(--surface-2)'; e.currentTarget.style.color = 'var(--text)'; }}
onMouseLeave={e => { e.currentTarget.style.background = 'none'; e.currentTarget.style.color = 'var(--text-muted)'; }}>
<IcoDots />
</button>
</td>
</tr>
);
})}
</tbody>
</table>
<div style={{ padding: '0 20px 16px' }}>
<AdminPagination page={safePage} setPage={setPage} pageSize={pageSize} setPageSize={setPageSize} total={filtered.length} />
</div>
</div>
)}
</div>
{/* Menu ⋮ contextuel */}
{openMenu && (
<>
<div style={{ position: 'fixed', inset: 0, zIndex: 299 }} onClick={() => setOpenMenu(null)} />
<div style={{
position: 'fixed', left: openMenu.x, top: openMenu.y,
transform: 'translateX(-100%) translateY(4px)', zIndex: 300,
background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.15)', padding: '4px 0', minWidth: 200,
}}>
<MenuBtn icon={<IcoEdit />} label={openMenu.user.role === 'admin' ? 'Rétrograder en utilisateur' : 'Promouvoir en admin'} onClick={() => { setOpenMenu(null); toggleRole(openMenu.user); }} />
<MenuBtn icon={<IcoSparkle />} label={openMenu.user.ai_enabled ? "Désactiver l'accès IA" : "Activer l'accès IA"} onClick={() => { setOpenMenu(null); toggleAiAccess(openMenu.user); }} />
{!openMenu.user.email_verified && <>
<MenuBtn icon={<IcoMail />} label="Renvoyer l'invitation" onClick={() => { setOpenMenu(null); resendInvitation(openMenu.user); }} />
<MenuBtn icon={<IcoCheck />} label="Vérifier l'email" onClick={() => { setOpenMenu(null); verifyEmail(openMenu.user); }} />
<MenuBtn icon={<IcoMail />} label="Nouvelle demande de vérification" onClick={() => { setOpenMenu(null); resendVerifEmail(openMenu.user); }} />
</>}
{openMenu.user.id !== currentUserId && <>
{(!openMenu.user.status || openMenu.user.status === 'active') && <MenuBtn icon={<IcoPause />} label="Désactiver" onClick={() => { setOpenMenu(null); setStatus(openMenu.user, 'deactivated'); }} />}
{openMenu.user.status === 'deactivated' && <MenuBtn icon={<IcoPlay />} label="Réactiver" onClick={() => { setOpenMenu(null); setStatus(openMenu.user, 'active'); }} />}
{openMenu.user.status === 'locked' && <MenuBtn icon={<IcoPlay />} label="Déverrouiller" onClick={() => { setOpenMenu(null); setStatus(openMenu.user, 'active'); }} />}
<div style={{ height: 1, background: 'var(--border)', margin: '4px 0' }} />
<MenuBtn icon={<IcoTrash />} label="Supprimer" danger onClick={() => { setOpenMenu(null); deleteUser(openMenu.user); }} />
</>}
</div>
</>
)}
<InviteUserModal
open={showInvite}
onClose={() => setShowInvite(false)}
onSent={() => load()}
/>
<ResendInviteModal
user={resendTarget}
onClose={() => setResendTarget(null)}
onSuccess={email => {
load();
setBanner({ ok: true, msg: `Invitation renvoyée à ${email}. Le lien est valable 7 jours.` });
}}
/>
<CreateUserModal
open={showCreate}
onClose={() => setShowCreate(false)}
onCreated={() => load()}
/>
<ConfirmModal
open={!!confirmAction}
title={confirmAction?.title}
message={confirmAction?.message}
confirmLabel={confirmAction?.confirmLabel}
onConfirm={async () => {
try { await confirmAction.onConfirm(); }
catch (e) { setErr(e.message); }
finally { setConfirmAction(null); }
}}
onCancel={() => setConfirmAction(null)}
/>
</>
);
}