Some checks are pending
CD Production (Hetzner k3s) / Promote Images (preprod → prod) (push) Waiting to run
CD Production (Hetzner k3s) / Deploy to k3s (xpeditis-prod) (push) Blocked by required conditions
CD Production (Hetzner k3s) / Smoke Tests (push) Blocked by required conditions
CD Production (Hetzner k3s) / Deployment Summary (push) Blocked by required conditions
CD Production (Hetzner k3s) / Notify Success (push) Blocked by required conditions
CD Production (Hetzner k3s) / Notify Failure (push) Blocked by required conditions
Aligns main with the complete application codebase (cicd branch). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
666 lines
32 KiB
TypeScript
666 lines
32 KiB
TypeScript
/**
|
||
* User Management Page
|
||
*
|
||
* Manage organization users, roles, and invitations
|
||
*/
|
||
|
||
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||
import { listUsers, updateUser, deleteUser, canInviteUser } from '@/lib/api';
|
||
import { createInvitation, listInvitations, cancelInvitation } from '@/lib/api/invitations';
|
||
import { useAuth } from '@/lib/context/auth-context';
|
||
import Link from 'next/link';
|
||
import ExportButton from '@/components/ExportButton';
|
||
|
||
const PAGE_SIZE = 5;
|
||
|
||
function Pagination({
|
||
page,
|
||
total,
|
||
onPage,
|
||
}: {
|
||
page: number;
|
||
total: number;
|
||
onPage: (p: number) => void;
|
||
}) {
|
||
const totalPages = Math.ceil(total / PAGE_SIZE);
|
||
if (totalPages <= 1) return null;
|
||
|
||
return (
|
||
<div className="px-6 py-3 flex items-center justify-between border-t border-gray-200">
|
||
<p className="text-sm text-gray-500">
|
||
{Math.min((page - 1) * PAGE_SIZE + 1, total)}–{Math.min(page * PAGE_SIZE, total)} sur {total}
|
||
</p>
|
||
<div className="flex items-center gap-1">
|
||
<button
|
||
onClick={() => onPage(page - 1)}
|
||
disabled={page === 1}
|
||
className="px-3 py-1 rounded text-sm border border-gray-300 disabled:opacity-40 hover:bg-gray-50"
|
||
>
|
||
‹
|
||
</button>
|
||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
|
||
<button
|
||
key={p}
|
||
onClick={() => onPage(p)}
|
||
className={`px-3 py-1 rounded text-sm border ${
|
||
p === page
|
||
? 'bg-blue-600 text-white border-blue-600'
|
||
: 'border-gray-300 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
<button
|
||
onClick={() => onPage(page + 1)}
|
||
disabled={page === totalPages}
|
||
className="px-3 py-1 rounded text-sm border border-gray-300 disabled:opacity-40 hover:bg-gray-50"
|
||
>
|
||
›
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default function UsersManagementPage() {
|
||
const router = useRouter();
|
||
const queryClient = useQueryClient();
|
||
const { user: currentUser } = useAuth();
|
||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
|
||
const [usersPage, setUsersPage] = useState(1);
|
||
const [invitationsPage, setInvitationsPage] = useState(1);
|
||
const [inviteForm, setInviteForm] = useState({
|
||
email: '',
|
||
firstName: '',
|
||
lastName: '',
|
||
role: 'USER' as 'MANAGER' | 'USER' | 'VIEWER',
|
||
});
|
||
const [error, setError] = useState('');
|
||
const [success, setSuccess] = useState('');
|
||
|
||
const { data: users, isLoading } = useQuery({
|
||
queryKey: ['users'],
|
||
queryFn: () => listUsers(),
|
||
});
|
||
|
||
const { data: licenseStatus } = useQuery({
|
||
queryKey: ['canInvite'],
|
||
queryFn: () => canInviteUser(),
|
||
});
|
||
|
||
const { data: pendingInvitations } = useQuery({
|
||
queryKey: ['invitations'],
|
||
queryFn: () => listInvitations(),
|
||
});
|
||
|
||
const inviteMutation = useMutation({
|
||
mutationFn: (data: typeof inviteForm) => createInvitation(data),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||
queryClient.invalidateQueries({ queryKey: ['invitations'] });
|
||
setSuccess("Invitation envoyée avec succès ! L'utilisateur recevra un email avec un lien d'inscription.");
|
||
setShowInviteModal(false);
|
||
setInviteForm({ email: '', firstName: '', lastName: '', role: 'USER' });
|
||
setInvitationsPage(1);
|
||
setTimeout(() => setSuccess(''), 5000);
|
||
},
|
||
onError: (err: any) => {
|
||
setError(err.response?.data?.message || "Échec de l'envoi de l'invitation");
|
||
setTimeout(() => setError(''), 5000);
|
||
},
|
||
});
|
||
|
||
const changeRoleMutation = useMutation({
|
||
mutationFn: ({ id, role }: { id: string; role: 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER' }) =>
|
||
updateUser(id, { role }),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||
setSuccess('Rôle mis à jour avec succès');
|
||
setTimeout(() => setSuccess(''), 3000);
|
||
},
|
||
onError: (err: any) => {
|
||
setError(err.response?.data?.message || 'Échec de la mise à jour du rôle');
|
||
setTimeout(() => setError(''), 5000);
|
||
},
|
||
});
|
||
|
||
const toggleActiveMutation = useMutation({
|
||
mutationFn: ({ id, isActive }: { id: string; isActive: boolean }) =>
|
||
updateUser(id, { isActive: !isActive }),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||
setSuccess("Statut de l'utilisateur mis à jour avec succès");
|
||
setTimeout(() => setSuccess(''), 3000);
|
||
},
|
||
onError: (err: any) => {
|
||
setError(err.response?.data?.message || 'Échec de la mise à jour du statut');
|
||
setTimeout(() => setError(''), 5000);
|
||
},
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: (id: string) => deleteUser(id),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['users'] });
|
||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||
setSuccess('Utilisateur supprimé avec succès');
|
||
setTimeout(() => setSuccess(''), 3000);
|
||
},
|
||
onError: (err: any) => {
|
||
setError(err.response?.data?.message || "Échec de la suppression de l'utilisateur");
|
||
setTimeout(() => setError(''), 5000);
|
||
},
|
||
});
|
||
|
||
const cancelInvitationMutation = useMutation({
|
||
mutationFn: (id: string) => cancelInvitation(id),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ['invitations'] });
|
||
queryClient.invalidateQueries({ queryKey: ['canInvite'] });
|
||
setSuccess('Invitation annulée avec succès');
|
||
setTimeout(() => setSuccess(''), 3000);
|
||
},
|
||
onError: (err: any) => {
|
||
setError(err.response?.data?.message || "Échec de l'annulation de l'invitation");
|
||
setTimeout(() => setError(''), 5000);
|
||
},
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (currentUser && currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER') {
|
||
router.push('/dashboard');
|
||
}
|
||
}, [currentUser, router]);
|
||
|
||
if (!currentUser || (currentUser.role !== 'ADMIN' && currentUser.role !== 'MANAGER')) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-screen">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const handleInvite = (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setError('');
|
||
inviteMutation.mutate(inviteForm);
|
||
};
|
||
|
||
const handleRoleChange = (userId: string, newRole: string) => {
|
||
changeRoleMutation.mutate({ id: userId, role: newRole as 'ADMIN' | 'MANAGER' | 'USER' | 'VIEWER' });
|
||
};
|
||
|
||
const handleToggleActive = (userId: string, isActive: boolean) => {
|
||
if (window.confirm(`Êtes-vous sûr de vouloir ${isActive ? 'désactiver' : 'activer'} cet utilisateur ?`)) {
|
||
toggleActiveMutation.mutate({ id: userId, isActive });
|
||
}
|
||
};
|
||
|
||
const handleDelete = (userId: string) => {
|
||
if (window.confirm('Êtes-vous sûr de vouloir supprimer cet utilisateur ? Cette action est irréversible.')) {
|
||
deleteMutation.mutate(userId);
|
||
}
|
||
};
|
||
|
||
const handleCancelInvitation = (invId: string, name: string) => {
|
||
if (window.confirm(`Annuler l'invitation envoyée à ${name} ?`)) {
|
||
cancelInvitationMutation.mutate(invId);
|
||
}
|
||
};
|
||
|
||
const getRoleBadgeColor = (role: string) => {
|
||
const colors: Record<string, string> = {
|
||
ADMIN: 'bg-red-100 text-red-800',
|
||
MANAGER: 'bg-blue-100 text-blue-800',
|
||
USER: 'bg-green-100 text-green-800',
|
||
VIEWER: 'bg-gray-100 text-gray-800',
|
||
};
|
||
return colors[role] || 'bg-gray-100 text-gray-800';
|
||
};
|
||
|
||
const allUsers = users?.users || [];
|
||
const pagedUsers = allUsers.slice((usersPage - 1) * PAGE_SIZE, usersPage * PAGE_SIZE);
|
||
|
||
const allPending = (pendingInvitations || []).filter(inv => !inv.isUsed);
|
||
const pagedInvitations = allPending.slice((invitationsPage - 1) * PAGE_SIZE, invitationsPage * PAGE_SIZE);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
{/* License Warning */}
|
||
{licenseStatus && !licenseStatus.canInvite && (
|
||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||
<div className="flex items-start">
|
||
<div className="flex-shrink-0">
|
||
<svg className="h-5 w-5 text-amber-400" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
|
||
</svg>
|
||
</div>
|
||
<div className="ml-3 flex-1">
|
||
<h3 className="text-sm font-medium text-amber-800">Limite de licences atteinte</h3>
|
||
<p className="mt-1 text-sm text-amber-700">
|
||
Votre organisation a utilisé toutes les licences disponibles ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses}).
|
||
Mettez à niveau votre abonnement pour inviter plus d'utilisateurs.
|
||
</p>
|
||
<div className="mt-3">
|
||
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-amber-800 hover:text-amber-900 underline">
|
||
Mettre à niveau l'abonnement
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* License Usage Info */}
|
||
{licenseStatus && licenseStatus.canInvite && licenseStatus.availableLicenses <= 2 && licenseStatus.maxLicenses !== -1 && (
|
||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center">
|
||
<svg className="h-5 w-5 text-blue-400 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
|
||
</svg>
|
||
<span className="text-sm text-blue-800">
|
||
{licenseStatus.availableLicenses} licence{licenseStatus.availableLicenses !== 1 ? 's' : ''} restante{licenseStatus.availableLicenses !== 1 ? 's' : ''} ({licenseStatus.usedLicenses}/{licenseStatus.maxLicenses} utilisées)
|
||
</span>
|
||
</div>
|
||
<Link href="/dashboard/settings/subscription" className="text-sm font-medium text-blue-600 hover:text-blue-800">
|
||
Gérer l'abonnement
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h1 className="text-2xl font-bold text-gray-900">Gestion des Utilisateurs</h1>
|
||
<p className="text-sm text-gray-500 mt-1">Gérez les membres de l'équipe et leurs permissions</p>
|
||
</div>
|
||
<div className="flex items-center space-x-3">
|
||
<ExportButton
|
||
data={allUsers}
|
||
filename="utilisateurs"
|
||
columns={[
|
||
{ key: 'firstName', label: 'Prénom' },
|
||
{ key: 'lastName', label: 'Nom' },
|
||
{ key: 'email', label: 'Email' },
|
||
{ key: 'role', label: 'Rôle', format: (v) => ({ ADMIN: 'Administrateur', MANAGER: 'Manager', USER: 'Utilisateur', VIEWER: 'Lecteur' }[v] || v) },
|
||
{ key: 'isActive', label: 'Statut', format: (v) => v ? 'Actif' : 'Inactif' },
|
||
{ key: 'createdAt', label: 'Date de création', format: (v) => v ? new Date(v).toLocaleDateString('fr-FR') : '' },
|
||
]}
|
||
/>
|
||
{licenseStatus?.canInvite ? (
|
||
<button
|
||
onClick={() => setShowInviteModal(true)}
|
||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700"
|
||
>
|
||
<span className="mr-2">+</span>
|
||
Inviter un utilisateur
|
||
</button>
|
||
) : (
|
||
<Link
|
||
href="/dashboard/settings/subscription"
|
||
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700"
|
||
>
|
||
<span className="mr-2">+</span>
|
||
Mettre à niveau
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{success && (
|
||
<div className="rounded-md bg-green-50 p-4">
|
||
<div className="text-sm text-green-800">{success}</div>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<div className="rounded-md bg-red-50 p-4">
|
||
<div className="text-sm text-red-800">{error}</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Users Table */}
|
||
<div className="bg-white rounded-lg shadow">
|
||
<div className="px-6 py-4 border-b border-gray-200">
|
||
<h2 className="text-lg font-medium text-gray-900">Utilisateurs</h2>
|
||
{allUsers.length > 0 && (
|
||
<p className="text-sm text-gray-500 mt-1">{allUsers.length} membre{allUsers.length !== 1 ? 's' : ''}</p>
|
||
)}
|
||
</div>
|
||
{isLoading ? (
|
||
<div className="px-6 py-12 text-center text-gray-500">
|
||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||
Chargement des utilisateurs...
|
||
</div>
|
||
) : pagedUsers.length > 0 ? (
|
||
<>
|
||
<div className="overflow-x-auto overflow-y-visible">
|
||
<table className="min-w-full divide-y divide-gray-200">
|
||
<thead className="bg-gray-50">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Utilisateur</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rôle</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date de création</th>
|
||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{pagedUsers.map(user => (
|
||
<tr key={user.id} className="hover:bg-gray-50">
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="flex items-center">
|
||
<div className="flex-shrink-0 h-10 w-10 bg-blue-600 rounded-full flex items-center justify-center text-white font-semibold">
|
||
{user.firstName[0]}{user.lastName[0]}
|
||
</div>
|
||
<div className="ml-4">
|
||
<div className="text-sm font-medium text-gray-900">{user.firstName} {user.lastName}</div>
|
||
<div className="text-sm text-gray-500">{user.email}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="text-sm text-gray-900">{user.email}</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<select
|
||
value={user.role}
|
||
onChange={e => handleRoleChange(user.id, e.target.value)}
|
||
className={`text-xs font-semibold rounded-full px-3 py-1 ${getRoleBadgeColor(user.role)}`}
|
||
disabled={
|
||
changeRoleMutation.isPending ||
|
||
(user.role === 'ADMIN' && currentUser?.role !== 'ADMIN') ||
|
||
user.id === currentUser?.id
|
||
}
|
||
>
|
||
{currentUser?.role === 'ADMIN' && <option value="ADMIN">Admin</option>}
|
||
<option value="MANAGER">Manager</option>
|
||
<option value="USER">User</option>
|
||
<option value="VIEWER">Viewer</option>
|
||
</select>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${user.isActive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}>
|
||
{user.isActive ? 'Actif' : 'Inactif'}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||
{new Date(user.createdAt).toLocaleDateString('fr-FR')}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||
<button
|
||
onClick={(e) => {
|
||
if (openMenuId === user.id) {
|
||
setOpenMenuId(null);
|
||
setMenuPosition(null);
|
||
} else {
|
||
const rect = e.currentTarget.getBoundingClientRect();
|
||
setMenuPosition({ top: rect.bottom + 5, left: rect.left - 180 });
|
||
setOpenMenuId(user.id);
|
||
}
|
||
}}
|
||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||
>
|
||
<svg className="w-5 h-5 text-gray-600" fill="currentColor" viewBox="0 0 20 20">
|
||
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
|
||
</svg>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<Pagination page={usersPage} total={allUsers.length} onPage={p => { setUsersPage(p); setOpenMenuId(null); }} />
|
||
</>
|
||
) : (
|
||
<div className="px-6 py-12 text-center">
|
||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
|
||
</svg>
|
||
<h3 className="mt-2 text-sm font-medium text-gray-900">Aucun utilisateur</h3>
|
||
<p className="mt-1 text-sm text-gray-500">Commencez par inviter un membre de l'équipe</p>
|
||
<div className="mt-6">
|
||
{licenseStatus?.canInvite ? (
|
||
<button onClick={() => setShowInviteModal(true)} className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
||
<span className="mr-2">+</span>
|
||
Inviter un utilisateur
|
||
</button>
|
||
) : (
|
||
<Link href="/dashboard/settings/subscription" className="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-amber-600 hover:bg-amber-700">
|
||
<span className="mr-2">+</span>
|
||
Mettre à niveau
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Pending Invitations */}
|
||
{allPending.length > 0 && (
|
||
<div className="bg-white rounded-lg shadow">
|
||
<div className="px-6 py-4 border-b border-gray-200">
|
||
<h2 className="text-lg font-medium text-gray-900">Invitations en attente</h2>
|
||
<p className="text-sm text-gray-500 mt-1">
|
||
Utilisateurs invités mais n'ayant pas encore créé leur compte — {allPending.length} invitation{allPending.length !== 1 ? 's' : ''}
|
||
</p>
|
||
</div>
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-gray-200">
|
||
<thead className="bg-gray-50">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Utilisateur</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Email</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rôle</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Expire le</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Statut</th>
|
||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{pagedInvitations.map(inv => {
|
||
const isExpired = new Date(inv.expiresAt) < new Date();
|
||
return (
|
||
<tr key={inv.id} className="hover:bg-gray-50">
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<div className="flex items-center">
|
||
<div className="flex-shrink-0 h-10 w-10 bg-gray-200 rounded-full flex items-center justify-center text-gray-500 font-semibold">
|
||
{inv.firstName[0]}{inv.lastName[0]}
|
||
</div>
|
||
<div className="ml-4">
|
||
<div className="text-sm font-medium text-gray-900">{inv.firstName} {inv.lastName}</div>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{inv.email}</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${getRoleBadgeColor(inv.role)}`}>
|
||
{inv.role}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||
{new Date(inv.expiresAt).toLocaleDateString('fr-FR')}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap">
|
||
<span className={`px-3 py-1 inline-flex text-xs leading-5 font-semibold rounded-full ${isExpired ? 'bg-red-100 text-red-800' : 'bg-yellow-100 text-yellow-800'}`}>
|
||
{isExpired ? 'Expirée' : 'En attente'}
|
||
</span>
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||
<button
|
||
onClick={() => handleCancelInvitation(inv.id, `${inv.firstName} ${inv.lastName}`)}
|
||
disabled={cancelInvitationMutation.isPending}
|
||
className="inline-flex items-center px-3 py-1 text-xs font-medium text-red-600 hover:text-red-800 hover:bg-red-50 rounded-md transition-colors disabled:opacity-50"
|
||
>
|
||
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
Annuler
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<Pagination page={invitationsPage} total={allPending.length} onPage={setInvitationsPage} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Actions Menu Modal */}
|
||
{openMenuId && menuPosition && (
|
||
<>
|
||
<div
|
||
className="fixed inset-0 z-[998]"
|
||
onClick={() => { setOpenMenuId(null); setMenuPosition(null); }}
|
||
/>
|
||
<div
|
||
className="fixed w-56 bg-white border-2 border-gray-300 rounded-lg shadow-2xl z-[999]"
|
||
style={{ top: `${menuPosition.top}px`, left: `${menuPosition.left}px` }}
|
||
>
|
||
<div className="py-2">
|
||
<button
|
||
onClick={() => {
|
||
const user = users?.users.find(u => u.id === openMenuId);
|
||
if (user) handleToggleActive(user.id, user.isActive);
|
||
setOpenMenuId(null);
|
||
setMenuPosition(null);
|
||
}}
|
||
disabled={toggleActiveMutation.isPending}
|
||
className="w-full px-4 py-3 text-left hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3 border-b border-gray-200"
|
||
>
|
||
{users?.users.find(u => u.id === openMenuId)?.isActive ? (
|
||
<>
|
||
<svg className="w-5 h-5 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" />
|
||
</svg>
|
||
<span className="text-sm font-medium text-gray-700">Désactiver</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||
</svg>
|
||
<span className="text-sm font-medium text-gray-700">Activer</span>
|
||
</>
|
||
)}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
if (openMenuId) handleDelete(openMenuId);
|
||
setOpenMenuId(null);
|
||
setMenuPosition(null);
|
||
}}
|
||
disabled={deleteMutation.isPending}
|
||
className="w-full px-4 py-3 text-left hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center space-x-3"
|
||
>
|
||
<svg className="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||
</svg>
|
||
<span className="text-sm font-medium text-red-600">Supprimer</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Invite Modal */}
|
||
{showInviteModal && (
|
||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||
<div className="flex items-center justify-center min-h-screen px-4 pt-4 pb-20 text-center sm:block sm:p-0">
|
||
<div className="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75" onClick={() => setShowInviteModal(false)} />
|
||
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full sm:p-6">
|
||
<div>
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h3 className="text-lg font-medium text-gray-900">Inviter un utilisateur</h3>
|
||
<button onClick={() => setShowInviteModal(false)} className="text-gray-400 hover:text-gray-500">
|
||
<svg className="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<form onSubmit={handleInvite} className="space-y-4">
|
||
<div className="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Prénom *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={inviteForm.firstName}
|
||
onChange={e => setInviteForm({ ...inviteForm, firstName: e.target.value })}
|
||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Nom *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={inviteForm.lastName}
|
||
onChange={e => setInviteForm({ ...inviteForm, lastName: e.target.value })}
|
||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Adresse email *</label>
|
||
<input
|
||
type="email"
|
||
required
|
||
value={inviteForm.email}
|
||
onChange={e => setInviteForm({ ...inviteForm, email: e.target.value })}
|
||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700">Rôle *</label>
|
||
<select
|
||
value={inviteForm.role}
|
||
onChange={e => setInviteForm({ ...inviteForm, role: e.target.value as any })}
|
||
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm px-3 py-2 border"
|
||
>
|
||
<option value="USER">Utilisateur</option>
|
||
<option value="MANAGER">Manager</option>
|
||
<option value="VIEWER">Lecteur</option>
|
||
</select>
|
||
</div>
|
||
<div className="mt-5 sm:mt-6 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
|
||
<button
|
||
type="submit"
|
||
disabled={inviteMutation.isPending}
|
||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 sm:col-start-2 sm:text-sm disabled:bg-gray-400"
|
||
>
|
||
{inviteMutation.isPending ? 'Envoi en cours...' : "Envoyer l'invitation"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowInviteModal(false)}
|
||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 sm:mt-0 sm:col-start-1 sm:text-sm"
|
||
>
|
||
Annuler
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|