Files
formation-manager-itinova/client/src/pages/AdminUsers.tsx

739 lines
26 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" | "formateur" | "super_formateur",
formateurId: undefined as number | undefined,
});
const [editForm, setEditForm] = useState({
name: "",
email: "",
username: "",
password: "",
role: "user" as "user" | "admin" | "formateur" | "super_formateur",
formateurId: undefined as number | undefined,
});
const { data: usersData, refetch } = trpc.users.listWithFormateurs.useQuery();
const users = usersData?.users || [];
const formateurs = usersData?.formateurs || [];
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",
formateurId: undefined,
});
},
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,
formateurId: createForm.formateurId,
});
};
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,
formateurId: user.formateurId,
});
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,
formateurId: editForm.formateurId,
};
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="page-title">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>
<SelectItem value="formateur">Formateur</SelectItem>
<SelectItem value="super_formateur">Super Formateur</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>
{/* Formateurs sans compte */}
{formateurs.filter(f => !f.hasAccount).length > 0 && (
<div className="mb-6 p-4 border border-orange-200 bg-orange-50 rounded-lg">
<h3 className="font-semibold text-orange-800 mb-2">
Formateurs sans compte utilisateur ({formateurs.filter(f => !f.hasAccount).length})
</h3>
<p className="text-sm text-orange-700 mb-4">
Ces formateurs existent dans le système mais n'ont pas de compte utilisateur. Créez un compte pour leur permettre de se connecter.
</p>
<div className="space-y-2">
{formateurs.filter(f => !f.hasAccount).map((formateur) => (
<div key={formateur.id} className="flex items-center justify-between bg-white p-3 rounded border">
<div>
<p className="font-medium">{formateur.nom}</p>
<p className="text-sm text-muted-foreground">{formateur.email || "Pas d'email"}</p>
</div>
<Button
size="sm"
onClick={() => {
setCreateForm({
openId: `formateur_${formateur.id}_${Date.now()}`,
name: formateur.nom,
email: formateur.email || "",
username: "",
password: "",
role: "formateur",
formateurId: formateur.id,
});
setCreateDialogOpen(true);
}}
>
<Plus className="h-4 w-4 mr-2" />
Créer un compte
</Button>
</div>
))}
</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, index) => (
<TableRow key={user.id} className={index % 2 === 1 ? "bg-blue-50/70" : ""}>
<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" : user.role === "formateur" ? "outline" : user.role === "super_formateur" ? "default" : "secondary"}>
{user.role === "admin" ? "Administrateur" : user.role === "formateur" ? "Formateur" : user.role === "super_formateur" ? "Super Formateur" : "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" | "formateur") =>
setCreateForm({ ...createForm, role: value })
}
>
<SelectTrigger id="createRole">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">Utilisateur</SelectItem>
<SelectItem value="admin">Administrateur</SelectItem>
<SelectItem value="formateur">Formateur</SelectItem>
<SelectItem value="super_formateur">Super Formateur</SelectItem>
</SelectContent>
</Select>
</div>
{(createForm.role === "formateur" || createForm.role === "super_formateur") && (
<div>
<Label htmlFor="createFormateur">Formateur associé</Label>
<Select
value={createForm.formateurId?.toString() || ""}
onValueChange={(value) =>
setCreateForm({ ...createForm, formateurId: value ? parseInt(value) : undefined })
}
>
<SelectTrigger id="createFormateur">
<SelectValue placeholder="Sélectionner un formateur" />
</SelectTrigger>
<SelectContent>
{formateurs.map((formateur: any) => (
<SelectItem key={formateur.id} value={formateur.id.toString()}>
{formateur.nom}
</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" | "formateur") =>
setEditForm({ ...editForm, role: value })
}
>
<SelectTrigger id="editRole">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">Utilisateur</SelectItem>
<SelectItem value="admin">Administrateur</SelectItem>
<SelectItem value="formateur">Formateur</SelectItem>
<SelectItem value="super_formateur">Super Formateur</SelectItem>
</SelectContent>
</Select>
</div>
{(editForm.role === "formateur" || editForm.role === "super_formateur") && (
<div>
<Label htmlFor="editFormateur">Formateur associé</Label>
<Select
value={editForm.formateurId?.toString() || ""}
onValueChange={(value) =>
setEditForm({ ...editForm, formateurId: value ? parseInt(value) : undefined })
}
>
<SelectTrigger id="editFormateur">
<SelectValue placeholder="Sélectionner un formateur" />
</SelectTrigger>
<SelectContent>
{formateurs.map((formateur: any) => (
<SelectItem key={formateur.id} value={formateur.id.toString()}>
{formateur.nom}
</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>
);
}