Modification apportée : - Le champ email est maintenant modifiable pour l'utilisateur adminServFormation - Mise à jour du message dans le formulaire d'édition : "Seuls l'email et le mot de passe peuvent être modifiés pour cet utilisateur" - Les champs nom et identifiant restent en lecture seule (grisés et désactivés) - Les champs email, mot de passe et rôle sont modifiables pour cet utilisateur spécial
639 lines
22 KiB
TypeScript
639 lines
22 KiB
TypeScript
import { useState } from "react";
|
||
import { trpc } from "@/lib/trpc";
|
||
import DashboardLayout from "@/components/DashboardLayout";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Label } from "@/components/ui/label";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/components/ui/table";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { toast } from "sonner";
|
||
import { Pencil, Trash2, UserCheck, UserX, Search, Plus, KeyRound } from "lucide-react";
|
||
|
||
export default function AdminUsers() {
|
||
const [searchTerm, setSearchTerm] = useState("");
|
||
const [filterRole, setFilterRole] = useState<string>("all");
|
||
const [filterStatus, setFilterStatus] = useState<string>("all");
|
||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||
const [resetPasswordDialogOpen, setResetPasswordDialogOpen] = useState(false);
|
||
const [selectedUser, setSelectedUser] = useState<any>(null);
|
||
const [createForm, setCreateForm] = useState({
|
||
openId: "",
|
||
name: "",
|
||
email: "",
|
||
username: "",
|
||
password: "",
|
||
role: "user" as "user" | "admin",
|
||
});
|
||
const [editForm, setEditForm] = useState({
|
||
name: "",
|
||
email: "",
|
||
username: "",
|
||
password: "",
|
||
role: "user" as "user" | "admin",
|
||
});
|
||
|
||
const { data: users = [], refetch } = trpc.users.list.useQuery();
|
||
|
||
const createMutation = trpc.users.create.useMutation({
|
||
onSuccess: () => {
|
||
toast.success("Utilisateur créé avec succès");
|
||
refetch();
|
||
setCreateDialogOpen(false);
|
||
setCreateForm({
|
||
openId: "",
|
||
name: "",
|
||
email: "",
|
||
username: "",
|
||
password: "",
|
||
role: "user",
|
||
});
|
||
},
|
||
onError: (error) => {
|
||
toast.error(`Erreur : ${error.message}`);
|
||
},
|
||
});
|
||
|
||
const updateMutation = trpc.users.update.useMutation({
|
||
onSuccess: () => {
|
||
toast.success("Utilisateur modifié avec succès");
|
||
refetch();
|
||
setEditDialogOpen(false);
|
||
},
|
||
onError: (error) => {
|
||
toast.error(`Erreur : ${error.message}`);
|
||
},
|
||
});
|
||
|
||
const toggleStatusMutation = trpc.users.toggleStatus.useMutation({
|
||
onSuccess: () => {
|
||
toast.success("Statut modifié avec succès");
|
||
refetch();
|
||
},
|
||
onError: (error) => {
|
||
toast.error(`Erreur : ${error.message}`);
|
||
},
|
||
});
|
||
|
||
const deleteMutation = trpc.users.delete.useMutation({
|
||
onSuccess: () => {
|
||
toast.success("Utilisateur supprimé avec succès");
|
||
refetch();
|
||
setDeleteDialogOpen(false);
|
||
},
|
||
onError: (error) => {
|
||
toast.error(`Erreur : ${error.message}`);
|
||
},
|
||
});
|
||
|
||
const resetPasswordMutation = trpc.users.requestPasswordReset.useMutation({
|
||
onSuccess: () => {
|
||
toast.success("Email de réinitialisation envoyé avec succès");
|
||
setResetPasswordDialogOpen(false);
|
||
},
|
||
onError: (error) => {
|
||
toast.error(`Erreur : ${error.message}`);
|
||
},
|
||
});
|
||
|
||
const handleCreate = () => {
|
||
setCreateDialogOpen(true);
|
||
};
|
||
|
||
const handleSaveCreate = () => {
|
||
if (!createForm.openId) {
|
||
toast.error("L'identifiant OpenID est obligatoire");
|
||
return;
|
||
}
|
||
|
||
createMutation.mutate({
|
||
openId: createForm.openId,
|
||
name: createForm.name || undefined,
|
||
email: createForm.email || undefined,
|
||
username: createForm.username || undefined,
|
||
password: createForm.password || undefined,
|
||
role: createForm.role,
|
||
isActive: true,
|
||
});
|
||
};
|
||
|
||
const handleEdit = (user: any) => {
|
||
setSelectedUser(user);
|
||
setEditForm({
|
||
name: user.name || "",
|
||
email: user.email || "",
|
||
username: user.username || "",
|
||
password: "", // Toujours vide pour la sécurité
|
||
role: user.role,
|
||
});
|
||
setEditDialogOpen(true);
|
||
};
|
||
|
||
const handleSaveEdit = () => {
|
||
if (!selectedUser) return;
|
||
|
||
// Ne pas envoyer username et password s'ils sont vides
|
||
const updateData: any = {
|
||
id: selectedUser.id,
|
||
name: editForm.name || undefined,
|
||
email: editForm.email || undefined,
|
||
role: editForm.role,
|
||
};
|
||
|
||
if (editForm.username) {
|
||
updateData.username = editForm.username;
|
||
}
|
||
|
||
if (editForm.password) {
|
||
updateData.password = editForm.password;
|
||
}
|
||
|
||
updateMutation.mutate(updateData);
|
||
};
|
||
|
||
const handleToggleStatus = (user: any) => {
|
||
toggleStatusMutation.mutate({
|
||
id: user.id,
|
||
isActive: !user.isActive,
|
||
});
|
||
};
|
||
|
||
const handleDelete = (user: any) => {
|
||
setSelectedUser(user);
|
||
setDeleteDialogOpen(true);
|
||
};
|
||
|
||
const confirmDelete = () => {
|
||
if (!selectedUser) return;
|
||
deleteMutation.mutate({ id: selectedUser.id });
|
||
};
|
||
|
||
const handleResetPassword = (user: any) => {
|
||
setSelectedUser(user);
|
||
setResetPasswordDialogOpen(true);
|
||
};
|
||
|
||
const confirmResetPassword = () => {
|
||
if (!selectedUser) return;
|
||
resetPasswordMutation.mutate({ id: selectedUser.id });
|
||
};
|
||
|
||
// Filtrage des utilisateurs
|
||
const filteredUsers = users.filter((user) => {
|
||
const matchesSearch =
|
||
searchTerm === "" ||
|
||
user.name?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
user.email?.toLowerCase().includes(searchTerm.toLowerCase());
|
||
|
||
const matchesRole = filterRole === "all" || user.role === filterRole;
|
||
const matchesStatus =
|
||
filterStatus === "all" ||
|
||
(filterStatus === "active" && user.isActive) ||
|
||
(filterStatus === "inactive" && !user.isActive);
|
||
|
||
return matchesSearch && matchesRole && matchesStatus;
|
||
});
|
||
|
||
const formatDate = (date: Date | string | null) => {
|
||
if (!date) return "N/A";
|
||
const d = new Date(date);
|
||
return d.toLocaleDateString("fr-FR", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
year: "numeric",
|
||
});
|
||
};
|
||
|
||
return (
|
||
<DashboardLayout>
|
||
<div className="mb-6 flex justify-between items-start">
|
||
<div>
|
||
<h1 className="text-3xl font-bold mb-2">Gestion des Utilisateurs</h1>
|
||
<p className="text-muted-foreground">
|
||
Gérez les comptes utilisateurs de l'application
|
||
</p>
|
||
</div>
|
||
<Button onClick={handleCreate}>
|
||
<Plus className="h-4 w-4 mr-2" />
|
||
Ajouter un utilisateur
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Filtres */}
|
||
<div className="mb-6 flex flex-wrap gap-4">
|
||
<div className="flex-1 min-w-[200px]">
|
||
<Label htmlFor="search">Rechercher</Label>
|
||
<div className="relative">
|
||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||
<Input
|
||
id="search"
|
||
placeholder="Nom ou email..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="pl-8"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="w-[200px]">
|
||
<Label htmlFor="filterRole">Rôle</Label>
|
||
<Select value={filterRole} onValueChange={setFilterRole}>
|
||
<SelectTrigger id="filterRole">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">Tous les rôles</SelectItem>
|
||
<SelectItem value="admin">Administrateur</SelectItem>
|
||
<SelectItem value="user">Utilisateur</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="w-[200px]">
|
||
<Label htmlFor="filterStatus">Statut</Label>
|
||
<Select value={filterStatus} onValueChange={setFilterStatus}>
|
||
<SelectTrigger id="filterStatus">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">Tous les statuts</SelectItem>
|
||
<SelectItem value="active">Actif</SelectItem>
|
||
<SelectItem value="inactive">Inactif</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Compteur de résultats */}
|
||
<div className="mb-4">
|
||
<p className="text-sm text-muted-foreground">
|
||
{filteredUsers.length} utilisateur(s) trouvé(s)
|
||
</p>
|
||
</div>
|
||
|
||
{/* Tableau des utilisateurs */}
|
||
<div className="border rounded-lg">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>ID</TableHead>
|
||
<TableHead>Nom</TableHead>
|
||
<TableHead>Email</TableHead>
|
||
<TableHead>Identifiant</TableHead>
|
||
<TableHead>Rôle</TableHead>
|
||
<TableHead>Statut</TableHead>
|
||
<TableHead>Dernière connexion</TableHead>
|
||
<TableHead>Créé le</TableHead>
|
||
<TableHead className="text-right">Actions</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{filteredUsers.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||
Aucun utilisateur trouvé
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
filteredUsers.map((user) => (
|
||
<TableRow key={user.id}>
|
||
<TableCell className="font-medium">{user.id}</TableCell>
|
||
<TableCell>{user.name || "N/A"}</TableCell>
|
||
<TableCell>{user.email || "N/A"}</TableCell>
|
||
<TableCell>{user.username || "N/A"}</TableCell>
|
||
<TableCell>
|
||
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
|
||
{user.role === "admin" ? "Administrateur" : "Utilisateur"}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>
|
||
<Badge variant={user.isActive ? "default" : "destructive"}>
|
||
{user.isActive ? "Actif" : "Inactif"}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell>{formatDate(user.lastSignedIn)}</TableCell>
|
||
<TableCell>{formatDate(user.createdAt)}</TableCell>
|
||
<TableCell className="text-right">
|
||
<div className="flex justify-end gap-2">
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleEdit(user)}
|
||
title="Modifier"
|
||
>
|
||
<Pencil className="h-4 w-4" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleResetPassword(user)}
|
||
title="Réinitialiser le mot de passe"
|
||
disabled={!user.email}
|
||
>
|
||
<KeyRound className="h-4 w-4 text-blue-500" />
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleToggleStatus(user)}
|
||
title={user.isActive ? "Désactiver" : "Activer"}
|
||
>
|
||
{user.isActive ? (
|
||
<UserX className="h-4 w-4 text-orange-500" />
|
||
) : (
|
||
<UserCheck className="h-4 w-4 text-green-500" />
|
||
)}
|
||
</Button>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
onClick={() => handleDelete(user)}
|
||
title={user.username === "adminServFormation" ? "Cet utilisateur ne peut pas être supprimé" : "Supprimer"}
|
||
disabled={user.username === "adminServFormation"}
|
||
>
|
||
<Trash2 className={user.username === "adminServFormation" ? "h-4 w-4 text-gray-300" : "h-4 w-4 text-red-500"} />
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
{/* Dialog de création */}
|
||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Créer un nouvel utilisateur</DialogTitle>
|
||
<DialogDescription>
|
||
Ajoutez un nouvel utilisateur à l'application
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<Label htmlFor="createOpenId">Identifiant OpenID *</Label>
|
||
<Input
|
||
id="createOpenId"
|
||
value={createForm.openId}
|
||
onChange={(e) => setCreateForm({ ...createForm, openId: e.target.value })}
|
||
placeholder="Identifiant unique de l'utilisateur"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="createName">Nom</Label>
|
||
<Input
|
||
id="createName"
|
||
value={createForm.name}
|
||
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||
placeholder="Nom de l'utilisateur"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="createEmail">Email</Label>
|
||
<Input
|
||
id="createEmail"
|
||
type="email"
|
||
value={createForm.email}
|
||
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
|
||
placeholder="email@exemple.com"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="createUsername">Identifiant de connexion</Label>
|
||
<Input
|
||
id="createUsername"
|
||
value={createForm.username}
|
||
onChange={(e) => setCreateForm({ ...createForm, username: e.target.value })}
|
||
placeholder="Identifiant unique pour la connexion"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="createPassword">Mot de passe</Label>
|
||
<Input
|
||
id="createPassword"
|
||
type="password"
|
||
value={createForm.password}
|
||
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
|
||
placeholder="Minimum 6 caractères"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="createRole">Rôle</Label>
|
||
<Select
|
||
value={createForm.role}
|
||
onValueChange={(value: "user" | "admin") =>
|
||
setCreateForm({ ...createForm, role: value })
|
||
}
|
||
>
|
||
<SelectTrigger id="createRole">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="user">Utilisateur</SelectItem>
|
||
<SelectItem value="admin">Administrateur</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||
Annuler
|
||
</Button>
|
||
<Button onClick={handleSaveCreate} disabled={createMutation.isPending}>
|
||
{createMutation.isPending ? "Création..." : "Créer"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Dialog de modification */}
|
||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Modifier l'utilisateur</DialogTitle>
|
||
<DialogDescription>
|
||
{selectedUser?.username === "adminServFormation"
|
||
? "Seuls l'email et le mot de passe peuvent être modifiés pour cet utilisateur"
|
||
: "Modifiez les informations de l'utilisateur"}
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<Label htmlFor="editName">Nom</Label>
|
||
<Input
|
||
id="editName"
|
||
value={editForm.name}
|
||
onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||
placeholder="Nom de l'utilisateur"
|
||
disabled={selectedUser?.username === "adminServFormation"}
|
||
className={selectedUser?.username === "adminServFormation" ? "bg-gray-100 cursor-not-allowed" : ""}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="editEmail">Email</Label>
|
||
<Input
|
||
id="editEmail"
|
||
type="email"
|
||
value={editForm.email}
|
||
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||
placeholder="email@exemple.com"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="editUsername">Identifiant de connexion</Label>
|
||
<Input
|
||
id="editUsername"
|
||
value={editForm.username}
|
||
onChange={(e) => setEditForm({ ...editForm, username: e.target.value })}
|
||
placeholder="Identifiant unique pour la connexion"
|
||
disabled={selectedUser?.username === "adminServFormation"}
|
||
className={selectedUser?.username === "adminServFormation" ? "bg-gray-100 cursor-not-allowed" : ""}
|
||
/>
|
||
{selectedUser?.username !== "adminServFormation" && (
|
||
<p className="text-xs text-muted-foreground mt-1">Laissez vide pour ne pas modifier</p>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="editPassword">Nouveau mot de passe</Label>
|
||
<Input
|
||
id="editPassword"
|
||
type="password"
|
||
value={editForm.password}
|
||
onChange={(e) => setEditForm({ ...editForm, password: e.target.value })}
|
||
placeholder="Laissez vide pour ne pas modifier"
|
||
/>
|
||
<p className="text-xs text-muted-foreground mt-1">Minimum 6 caractères si modifié</p>
|
||
</div>
|
||
|
||
<div>
|
||
<Label htmlFor="editRole">Rôle</Label>
|
||
<Select
|
||
value={editForm.role}
|
||
onValueChange={(value: "user" | "admin") =>
|
||
setEditForm({ ...editForm, role: value })
|
||
}
|
||
>
|
||
<SelectTrigger id="editRole">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="user">Utilisateur</SelectItem>
|
||
<SelectItem value="admin">Administrateur</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||
Annuler
|
||
</Button>
|
||
<Button onClick={handleSaveEdit} disabled={updateMutation.isPending}>
|
||
{updateMutation.isPending ? "Enregistrement..." : "Enregistrer"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Dialog de réinitialisation de mot de passe */}
|
||
<Dialog open={resetPasswordDialogOpen} onOpenChange={setResetPasswordDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Réinitialiser le mot de passe</DialogTitle>
|
||
<DialogDescription>
|
||
Êtes-vous sûr de vouloir envoyer un email de réinitialisation de mot de passe à{" "}
|
||
<strong>{selectedUser?.name || selectedUser?.email}</strong> ?
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 my-4">
|
||
<p className="text-sm text-blue-900">
|
||
<strong>ℹ️ Information :</strong>
|
||
</p>
|
||
<ul className="text-sm text-blue-800 mt-2 space-y-1 list-disc list-inside">
|
||
<li>Un email sera envoyé à : <strong>{selectedUser?.email}</strong></li>
|
||
<li>Le lien de réinitialisation sera valide pendant 24 heures</li>
|
||
<li>Il ne pourra être utilisé qu'une seule fois</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setResetPasswordDialogOpen(false)}>
|
||
Annuler
|
||
</Button>
|
||
<Button
|
||
onClick={confirmResetPassword}
|
||
disabled={resetPasswordMutation.isPending}
|
||
>
|
||
{resetPasswordMutation.isPending ? "Envoi en cours..." : "Envoyer l'email"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
{/* Dialog de suppression */}
|
||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>Confirmer la suppression</DialogTitle>
|
||
<DialogDescription>
|
||
Êtes-vous sûr de vouloir supprimer l'utilisateur{" "}
|
||
<strong>{selectedUser?.name || selectedUser?.email}</strong> ?
|
||
Cette action est irréversible.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<DialogFooter>
|
||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||
Annuler
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
onClick={confirmDelete}
|
||
disabled={deleteMutation.isPending}
|
||
>
|
||
{deleteMutation.isPending ? "Suppression..." : "Supprimer"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</DashboardLayout>
|
||
);
|
||
}
|