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}
}
>
)}
);
}
// ── 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}
}
);
}
// ── 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.
) : (
| Utilisateur |
Statut |
Rôle |
Assistant IA |
2FA |
Inscrit le |
Nb de plateformes |
Nb d'investissements |
Nb de documents |
Taille des documents |
|
{paged.map(u => {
const [bg, fg] = avatarColor(u.display_name || u.email);
return (
{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)}
/>
>
);
}