830 lines
44 KiB
React
830 lines
44 KiB
React
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)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|