@@ -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: ' 5 px 10 px ', 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 ( 180 deg ) ' : ' none ', transition: ' transform .15 s ' }}>
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
{open && (
<div style={{
position: ' absolute ', top: ' calc ( 100 % + 6 px ) ', left: 0, zIndex: 200,
background: ' var ( -- surface ) ', border: ' 1 px solid var ( -- border ) ',
borderRadius: 8, boxShadow: ' 0 4 px 16 px rgba ( 0 , 0 , 0 , .12 ) ',
padding: ' 4 px 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: ' 7 px 14 px ', 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 > 2 FA < / 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: ' 20 px 20 px 14 px ', gap: 16, borderBottom: ' 1 px solid var ( -- border ) ', flexWrap: ' wrap ' }}>
<div>
<h3 style={{ margin: ' 0 0 3 px ' }}>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 > 2 FA < / 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 ) }
/ >
< / >