Gestion des utilisateurs
This commit is contained in:
@@ -1,146 +1,646 @@
|
||||
import { useState, useEffect, useCallback } from '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 { fmt, Badge } from './adminHelpers.jsx';
|
||||
import Modal from '../../components/Modal.jsx';
|
||||
import { fmt, Badge, UserStatusBadge } from './adminHelpers.jsx';
|
||||
|
||||
// ── 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>;
|
||||
|
||||
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',
|
||||
'2FA': u.totp_enabled ? 'Oui' : 'Non',
|
||||
'Inscrit le': u.created_at ? u.created_at.replace('T', ' ').slice(0, 16) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
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', '2FA', 'Inscrit le'];
|
||||
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 }) {
|
||||
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);
|
||||
} 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 création utilisateur ────────────────────────────────────────────
|
||||
function CreateUserModal({ open, onClose, onCreated }) {
|
||||
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>
|
||||
<input type="password" required minLength={8} autoComplete="new-password" value={form.password} onChange={e => set('password', e.target.value)} placeholder="8 caractères minimum" />
|
||||
</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 [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
const [confirmAction, setConfirmAction] = useState(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [showInvite, setShowInvite] = useState(false);
|
||||
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);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await api.get('/admin/users');
|
||||
setUsers(data);
|
||||
} catch (e) { setErr(e.message); }
|
||||
try { setLoading(true); setUsers(await api.get('/admin/users')); }
|
||||
catch (e) { setErr(e.message); }
|
||||
finally { setLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
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]);
|
||||
|
||||
const toggleRole = (u) => {
|
||||
// ── 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);
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────────────
|
||||
const confirm = (title, message, onConfirm, confirmLabel) =>
|
||||
setConfirmAction({ title, message, onConfirm, confirmLabel });
|
||||
|
||||
const toggleRole = u => {
|
||||
const newRole = u.role === 'admin' ? 'user' : 'admin';
|
||||
setConfirmAction({
|
||||
title: 'Changer le rôle',
|
||||
message: `Changer le rôle de ${u.display_name || u.email} → ${newRole === 'admin' ? 'Administrateur' : 'Utilisateur'} ?`,
|
||||
confirmLabel: 'Confirmer',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await api.patch(`/admin/users/${u.id}/role`, { role: newRole });
|
||||
load();
|
||||
} catch (e) { setErr('Erreur : ' + e.message); }
|
||||
finally { setConfirmAction(null); }
|
||||
},
|
||||
});
|
||||
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 resendVerif = async u => {
|
||||
try { await api.post('/auth/resend-verification', { email: u.email }); alert(`Email envoyé à ${u.email}`); }
|
||||
catch (e) { setErr(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 deleteUser = u => confirm("Supprimer l'utilisateur",
|
||||
`Supprimer définitivement ${u.display_name || u.email} ? Toutes ses données seront effacé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 });
|
||||
};
|
||||
|
||||
const verifyEmail = (u) => {
|
||||
setConfirmAction({
|
||||
title: 'Vérifier l\'email manuellement',
|
||||
message: `Marquer l'email de ${u.display_name || u.email} comme vérifié ?`,
|
||||
confirmLabel: 'Confirmer',
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await api.patch(`/admin/users/${u.id}/verify-email`, {});
|
||||
load();
|
||||
} catch (e) { setErr('Erreur : ' + e.message); }
|
||||
finally { setConfirmAction(null); }
|
||||
},
|
||||
});
|
||||
// ── 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 deleteUser = (u) => {
|
||||
setConfirmAction({
|
||||
title: 'Supprimer l\'utilisateur',
|
||||
message: `Supprimer définitivement ${u.display_name || u.email} ? Toutes ses données seront effacées.`,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await api.del(`/admin/users/${u.id}`);
|
||||
load();
|
||||
} catch (e) { setErr('Erreur : ' + e.message); }
|
||||
finally { setConfirmAction(null); }
|
||||
},
|
||||
});
|
||||
};
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (loading) return <p style={{ color: 'var(--text-muted)' }}>Chargement…</p>;
|
||||
if (err) return <p style={{ color: '#ef4444' }}>{err}</p>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3 style={{ margin: '0 0 4px' }}>Comptes utilisateurs</h3>
|
||||
<p className="text-muted" style={{ margin: '0 0 20px', fontSize: 'var(--fs-sm)' }}>
|
||||
{users.length} utilisateur{users.length !== 1 ? 's' : ''} enregistré{users.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 36 }}>ID</th>
|
||||
<th>Nom</th>
|
||||
<th>Email</th>
|
||||
<th>Email vérifié</th>
|
||||
<th>2FA</th>
|
||||
<th>Rôle</th>
|
||||
<th>Créé le</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map(u => (
|
||||
<tr key={u.id}>
|
||||
<td style={{ color: 'var(--text-muted)' }}>{u.id}</td>
|
||||
<td style={{ fontWeight: 500 }}>{u.display_name || <em style={{ color: 'var(--text-muted)' }}>—</em>}</td>
|
||||
<td>{u.email}</td>
|
||||
<td>
|
||||
{u.email_verified
|
||||
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--success-bg, #f0fdf4)', color: 'var(--success, #16a34a)', border: '1px solid #bbf7d0' }}>✓ Vérifié</span>
|
||||
: <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--warning-bg, #fffbeb)', color: 'var(--warning-text, #92400e)', border: '1px solid #fcd34d' }}>En attente</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
{u.totp_enabled
|
||||
? <span style={{ fontSize: 11, fontWeight: 600, padding: '2px 8px', borderRadius: 10, background: 'var(--primary-bg, #eff6ff)', color: 'var(--primary, #1e40af)', border: '1px solid #bfdbfe' }}>🔐 Activé</span>
|
||||
: <span style={{ fontSize: 11, color: 'var(--text-muted)' }}>—</span>
|
||||
}
|
||||
</td>
|
||||
<td><Badge role={u.role} /></td>
|
||||
<td style={{ color: 'var(--text-muted)', fontSize: 12 }}>{fmt(u.created_at)}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={() => toggleRole(u)}
|
||||
disabled={u.id === currentUserId && u.role === 'admin'}
|
||||
title={u.id === currentUserId ? 'Vous ne pouvez pas vous rétrograder' : ''}
|
||||
>
|
||||
{u.role === 'admin' ? '→ Utilisateur' : '→ Admin'}
|
||||
</button>
|
||||
{!u.email_verified && (
|
||||
<button className="btn btn-sm btn-outline" onClick={() => verifyEmail(u)}>
|
||||
Vérifier email
|
||||
</button>
|
||||
)}
|
||||
{u.id !== currentUserId && (
|
||||
<button className="btn btn-sm btn-danger" onClick={() => deleteUser(u)}>
|
||||
Supprimer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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>2FA</th>
|
||||
<th>Inscrit le</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>
|
||||
{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={{ 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' ? '→ Utilisateur' : '→ Admin'} onClick={() => { setOpenMenu(null); toggleRole(openMenu.user); }} />
|
||||
{!openMenu.user.email_verified && <>
|
||||
<MenuBtn icon={<IcoCheck />} label="Vérifier l'email" onClick={() => { setOpenMenu(null); verifyEmail(openMenu.user); }} />
|
||||
<MenuBtn icon={<IcoMail />} label="Renvoyer la vérification" onClick={() => { setOpenMenu(null); resendVerif(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)}
|
||||
/>
|
||||
|
||||
<CreateUserModal
|
||||
open={showCreate}
|
||||
onClose={() => setShowCreate(false)}
|
||||
onCreated={() => load()}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmAction}
|
||||
title={confirmAction?.title}
|
||||
message={confirmAction?.message}
|
||||
confirmLabel={confirmAction?.confirmLabel}
|
||||
onConfirm={confirmAction?.onConfirm}
|
||||
onConfirm={async () => {
|
||||
try { await confirmAction.onConfirm(); }
|
||||
catch (e) { setErr(e.message); }
|
||||
finally { setConfirmAction(null); }
|
||||
}}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -25,6 +25,33 @@ export function Badge({ role }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Badge statut utilisateur — dérivé de status + email_verified */
|
||||
export function UserStatusBadge({ status, emailVerified }) {
|
||||
// status: 'active' | 'deactivated' | 'locked'
|
||||
// Le statut affiché est une combinaison des deux champs
|
||||
let label, bg, color, border;
|
||||
if (status === 'deactivated') {
|
||||
label = 'Désactivé'; bg = 'rgba(100,116,139,.12)'; color = '#64748b'; border = 'rgba(100,116,139,.3)';
|
||||
} else if (status === 'locked') {
|
||||
label = 'Verrouillé'; bg = 'rgba(239,68,68,.12)'; color = '#dc2626'; border = 'rgba(239,68,68,.3)';
|
||||
} else if (!emailVerified) {
|
||||
label = 'En attente'; bg = 'rgba(245,158,11,.12)'; color = '#d97706'; border = 'rgba(245,158,11,.3)';
|
||||
} else {
|
||||
label = 'Actif'; bg = 'rgba(34,197,94,.12)'; color = '#16a34a'; border = 'rgba(34,197,94,.3)';
|
||||
}
|
||||
return (
|
||||
<span style={{
|
||||
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||
padding: '2px 10px', borderRadius: 12,
|
||||
fontSize: 11, fontWeight: 700,
|
||||
background: bg, color, border: `1px solid ${border}`,
|
||||
}}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: color, display: 'inline-block', flexShrink: 0 }} />
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }) {
|
||||
const ok = status === 'ok';
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user