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 = () => ; const IcoCheck = () => ; const IcoMail = () => ; const IcoPause = () => ; const IcoPlay = () => ; const IcoTrash = () => ; const IcoDots = () => ; const IcoSearch = () => ; const IcoSparkle = () => ; // 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 ( ); } function MenuBtn({ onClick, icon, label, danger = false }) { return ( ); } // ── 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 (
{open && (
)}
); } // ── 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 (
Page {page} sur {totalPages}
setPage(p => Math.max(1, p - 1))} disabled={page === 1}>‹ {pages[0] > 1 && <> setPage(1)}>1{pages[0] > 2 && }} {pages.map(n => setPage(n)} active={n === page}>{n})} {pages[pages.length - 1] < totalPages && <>{pages[pages.length - 1] < totalPages - 1 && } setPage(totalPages)}>{totalPages}} setPage(p => Math.min(totalPages, p + 1))} disabled={page === totalPages}>›
); } const Ellipsis = () => …; function PBtn({ onClick, disabled, active, children }) { return ( ); } // ── 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 ( Fermer ) : ( <> )} > {sent ? (

Invitation envoyée !

Un email a été envoyé à {form.email}.
Le lien est valable 7 jours.

) : ( <> {err &&
{err}
}
set('email', e.target.value)} />

L'invité recevra un email avec un lien sécurisé pour créer son compte. Son adresse email sera pré-remplie et non modifiable.

)}
); } // ── 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 ( } > {err &&
{err}
}

Renvoyer un nouveau lien d'invitation à {identity} ?
L'ancien lien sera révoqué et remplacé par un nouveau valable 7 jours.

); } // ── 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 ( } > {err &&
{err}
}
set('displayName', e.target.value)} placeholder="Prénom Nom" />
set('email', e.target.value)} placeholder="utilisateur@exemple.com" />
set('password', e.target.value)} placeholder={`${minPasswordLength} caractères minimum`} />
); } // ── 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 (
{open && (
{options.map(o => ( ))}
)}
); }; const today = new Date().toISOString().slice(0, 10); if (initialLoading) return

Chargement…

; if (err) return

{err}

; return ( <>
{/* Bannière succès/erreur */} {banner && ( setBanner(null)} style={{ margin: '12px 20px 0', borderRadius: 8 }} /> )} {/* En-tête */}

Comptes utilisateurs

Gérez les comptes et les accès des membres de l'application.

setSearch(e.target.value)} /> {search && ( )}
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')} />
{/* Filtres pills */}
{filtered.length !== users.length ? `${filtered.length} / ${users.length} utilisateur${users.length !== 1 ? 's' : ''}` : `${users.length} utilisateur${users.length !== 1 ? 's' : ''}` } {(filterRole !== 'all' || filterStatus !== 'all' || search) && ( )}
{aiConfig && !aiAvailable && (
{aiUnavailableReason}
)} {/* Tableau */} {filtered.length === 0 ? (

Aucun utilisateur ne correspond aux filtres.

) : (
{paged.map(u => { const [bg, fg] = avatarColor(u.display_name || u.email); return ( ); })}
Utilisateur Statut Rôle Assistant IA 2FA Inscrit le Nb de plateformes Nb d'investissements Nb de documents Taille des documents
{initials(u)}
{u.display_name || {u.email.split('@')[0]}} {u.id === currentUserId && (vous)}
{u.email}
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")} /> {u.totp_enabled ? 🔐 Actif : — } {fmt(u.created_at)} {u.nb_plateformes ?? 0} {u.nb_investissements ?? 0} {u.nb_documents ?? 0} {fmtOctets(u.taille_documents ?? 0)}
)}
{/* Menu ⋮ contextuel */} {openMenu && ( <>
setOpenMenu(null)} />
} label={openMenu.user.role === 'admin' ? 'Rétrograder en utilisateur' : 'Promouvoir en admin'} onClick={() => { setOpenMenu(null); toggleRole(openMenu.user); }} /> } 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 && <> } label="Renvoyer l'invitation" onClick={() => { setOpenMenu(null); resendInvitation(openMenu.user); }} /> } label="Vérifier l'email" onClick={() => { setOpenMenu(null); verifyEmail(openMenu.user); }} /> } label="Nouvelle demande de vérification" onClick={() => { setOpenMenu(null); resendVerifEmail(openMenu.user); }} /> } {openMenu.user.id !== currentUserId && <> {(!openMenu.user.status || openMenu.user.status === 'active') && } label="Désactiver" onClick={() => { setOpenMenu(null); setStatus(openMenu.user, 'deactivated'); }} />} {openMenu.user.status === 'deactivated' && } label="Réactiver" onClick={() => { setOpenMenu(null); setStatus(openMenu.user, 'active'); }} />} {openMenu.user.status === 'locked' && } label="Déverrouiller" onClick={() => { setOpenMenu(null); setStatus(openMenu.user, 'active'); }} />}
} label="Supprimer" danger onClick={() => { setOpenMenu(null); deleteUser(openMenu.user); }} /> }
)} setShowInvite(false)} onSent={() => load()} /> setResendTarget(null)} onSuccess={email => { load(); setBanner({ ok: true, msg: `Invitation renvoyée à ${email}. Le lien est valable 7 jours.` }); }} /> setShowCreate(false)} onCreated={() => load()} /> { try { await confirmAction.onConfirm(); } catch (e) { setErr(e.message); } finally { setConfirmAction(null); } }} onCancel={() => setConfirmAction(null)} /> ); }