diff --git a/client/src/App.tsx b/client/src/App.tsx index 042f61f..e1d991d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -11,6 +11,7 @@ import AdminFormations from "./pages/AdminFormations"; import AdminSequences from "./pages/AdminSequences"; import AdminApprenants from "./pages/AdminApprenants"; import AdminSequenceInscrits from "./pages/AdminSequenceInscrits"; +import AdminUsers from "./pages/AdminUsers"; import Inscription from "./pages/Inscription"; function Router() { @@ -24,6 +25,7 @@ function Router() { + {/* Final fallback route */} diff --git a/client/src/components/DashboardLayout.tsx b/client/src/components/DashboardLayout.tsx index 2f7d668..703803e 100644 --- a/client/src/components/DashboardLayout.tsx +++ b/client/src/components/DashboardLayout.tsx @@ -21,7 +21,7 @@ import { } from "@/components/ui/sidebar"; import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const"; import { useIsMobile } from "@/hooks/useMobile"; -import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, BarChart3 } from "lucide-react"; +import { LayoutDashboard, LogOut, PanelLeft, Users, GraduationCap, Calendar, BarChart3, UserCog } from "lucide-react"; import { CSSProperties, useEffect, useRef, useState } from "react"; import { useLocation } from "wouter"; import { DashboardLayoutSkeleton } from './DashboardLayoutSkeleton'; @@ -32,6 +32,7 @@ const menuItems = [ { icon: GraduationCap, label: "Formations", path: "/admin/formations" }, { icon: Calendar, label: "Séquences", path: "/admin/sequences" }, { icon: Users, label: "Apprenants", path: "/admin/apprenants" }, + { icon: UserCog, label: "Utilisateurs", path: "/admin/users" }, { icon: BarChart3, label: "Rapport Public Cible", path: "/admin/rapport-public-cible" }, ]; diff --git a/client/src/pages/AdminUsers.tsx b/client/src/pages/AdminUsers.tsx new file mode 100644 index 0000000..bed879b --- /dev/null +++ b/client/src/pages/AdminUsers.tsx @@ -0,0 +1,373 @@ +import { useState } from "react"; +import { trpc } from "@/lib/trpc"; +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 } from "lucide-react"; + +export default function AdminUsers() { + const [searchTerm, setSearchTerm] = useState(""); + const [filterRole, setFilterRole] = useState("all"); + const [filterStatus, setFilterStatus] = useState("all"); + const [editDialogOpen, setEditDialogOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [selectedUser, setSelectedUser] = useState(null); + const [editForm, setEditForm] = useState({ + name: "", + email: "", + role: "user" as "user" | "admin", + }); + + const { data: users = [], refetch } = trpc.users.list.useQuery(); + 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 handleEdit = (user: any) => { + setSelectedUser(user); + setEditForm({ + name: user.name || "", + email: user.email || "", + role: user.role, + }); + setEditDialogOpen(true); + }; + + const handleSaveEdit = () => { + if (!selectedUser) return; + + updateMutation.mutate({ + id: selectedUser.id, + name: editForm.name || undefined, + email: editForm.email || undefined, + role: editForm.role, + }); + }; + + 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 }); + }; + + // 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 ( +
+
+

Gestion des Utilisateurs

+

+ Gérez les comptes utilisateurs de l'application +

+
+ + {/* Filtres */} +
+
+ +
+ + setSearchTerm(e.target.value)} + className="pl-8" + /> +
+
+ +
+ + +
+ +
+ + +
+
+ + {/* Compteur de résultats */} +
+

+ {filteredUsers.length} utilisateur(s) trouvé(s) +

+
+ + {/* Tableau des utilisateurs */} +
+ + + + ID + Nom + Email + Rôle + Statut + Dernière connexion + Créé le + Actions + + + + {filteredUsers.length === 0 ? ( + + + Aucun utilisateur trouvé + + + ) : ( + filteredUsers.map((user) => ( + + {user.id} + {user.name || "N/A"} + {user.email || "N/A"} + + + {user.role === "admin" ? "Administrateur" : "Utilisateur"} + + + + + {user.isActive ? "Actif" : "Inactif"} + + + {formatDate(user.lastSignedIn)} + {formatDate(user.createdAt)} + +
+ + + +
+
+
+ )) + )} +
+
+
+ + {/* Dialog de modification */} + + + + Modifier l'utilisateur + + Modifiez les informations de l'utilisateur + + + +
+
+ + setEditForm({ ...editForm, name: e.target.value })} + placeholder="Nom de l'utilisateur" + /> +
+ +
+ + setEditForm({ ...editForm, email: e.target.value })} + placeholder="email@exemple.com" + /> +
+ +
+ + +
+
+ + + + + +
+
+ + {/* Dialog de suppression */} + + + + Confirmer la suppression + + Êtes-vous sûr de vouloir supprimer l'utilisateur{" "} + {selectedUser?.name || selectedUser?.email} ? + Cette action est irréversible. + + + + + + + + + +
+ ); +} diff --git a/drizzle/0006_lame_madrox.sql b/drizzle/0006_lame_madrox.sql new file mode 100644 index 0000000..25bed12 --- /dev/null +++ b/drizzle/0006_lame_madrox.sql @@ -0,0 +1 @@ +ALTER TABLE `users` ADD `isActive` boolean DEFAULT true NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..0357bea --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,500 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "0ad1d66e-8e34-4ec9-9e4a-12ff775a6d1b", + "prevId": "b9b19655-1104-4112-ac8d-d568013faea2", + "tables": { + "apprenants": { + "name": "apprenants", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prenom": { + "name": "prenom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "codeEtablissement": { + "name": "codeEtablissement", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fonction": { + "name": "fonction", + "type": "enum('directeur','chef_service','autre')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "apprenants_id": { + "name": "apprenants_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "apprenants_email_unique": { + "name": "apprenants_email_unique", + "columns": [ + "email" + ] + } + }, + "checkConstraint": {} + }, + "datesFormation": { + "name": "datesFormation", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateDebut": { + "name": "dateDebut", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateFin": { + "name": "dateFin", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ordre": { + "name": "ordre", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "datesFormation_id": { + "name": "datesFormation_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "formations": { + "name": "formations", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lienUnique": { + "name": "lienUnique", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actif": { + "name": "actif", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "formations_id": { + "name": "formations_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "formations_lienUnique_unique": { + "name": "formations_lienUnique_unique", + "columns": [ + "lienUnique" + ] + } + }, + "checkConstraint": {} + }, + "inscriptions": { + "name": "inscriptions", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "apprenantId": { + "name": "apprenantId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sequenceId": { + "name": "sequenceId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "enum('confirmee','liste_attente','annulee')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "dateInscription": { + "name": "dateInscription", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inscriptions_id": { + "name": "inscriptions_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "sequences": { + "name": "sequences", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "formationId": { + "name": "formationId", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "nom": { + "name": "nom", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "lieu": { + "name": "lieu", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "publicCible": { + "name": "publicCible", + "type": "enum('directeur','chef_service','autre')", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "capaciteMax": { + "name": "capaciteMax", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 12 + }, + "dateBlocage": { + "name": "dateBlocage", + "type": "datetime", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "statut": { + "name": "statut", + "type": "enum('ouverte','bloquee','terminee')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ouverte'" + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "sequences_id": { + "name": "sequences_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "checkConstraint": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "int", + "primaryKey": false, + "notNull": true, + "autoincrement": true + }, + "openId": { + "name": "openId", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "varchar(320)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "loginMethod": { + "name": "loginMethod", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "enum('user','admin')", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'user'" + }, + "isActive": { + "name": "isActive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "createdAt": { + "name": "createdAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + }, + "updatedAt": { + "name": "updatedAt", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "onUpdate": true, + "default": "(now())" + }, + "lastSignedIn": { + "name": "lastSignedIn", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(now())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "users_id": { + "name": "users_id", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": { + "users_openId_unique": { + "name": "users_openId_unique", + "columns": [ + "openId" + ] + } + }, + "checkConstraint": {} + } + }, + "views": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "tables": {}, + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 2596aee..c848ff7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1763050409917, "tag": "0005_cuddly_bushwacker", "breakpoints": true + }, + { + "idx": 6, + "version": "5", + "when": 1763412717239, + "tag": "0006_lame_madrox", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/schema.ts b/drizzle/schema.ts index 5d7693a..d7b3bb0 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -17,6 +17,8 @@ export const users = mysqlTable("users", { email: varchar("email", { length: 320 }), loginMethod: varchar("loginMethod", { length: 64 }), role: mysqlEnum("role", ["user", "admin"]).default("user").notNull(), + /** Statut du compte (actif/inactif) */ + isActive: boolean("isActive").default(true).notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(), diff --git a/server/db.ts b/server/db.ts index fcac569..7c1d6f7 100644 --- a/server/db.ts +++ b/server/db.ts @@ -373,3 +373,41 @@ export async function deleteInscription(id: number) { await db.delete(inscriptions).where(eq(inscriptions.id, id)); } + +// ==================== GESTION DES UTILISATEURS ==================== + +export async function getAllUsers() { + const db = await getDb(); + if (!db) return []; + + return await db.select().from(users); +} + +export async function getUserById(id: number) { + const db = await getDb(); + if (!db) return undefined; + + const result = await db.select().from(users).where(eq(users.id, id)).limit(1); + return result.length > 0 ? result[0] : undefined; +} + +export async function updateUser(id: number, data: Partial) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.update(users).set(data).where(eq(users.id, id)); +} + +export async function toggleUserStatus(id: number, isActive: boolean) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.update(users).set({ isActive }).where(eq(users.id, id)); +} + +export async function deleteUser(id: number) { + const db = await getDb(); + if (!db) throw new Error("Database not available"); + + await db.delete(users).where(eq(users.id, id)); +} diff --git a/server/routers.ts b/server/routers.ts index cee4b5d..2170316 100644 --- a/server/routers.ts +++ b/server/routers.ts @@ -537,6 +537,42 @@ export const appRouter = router({ }; }), }), + + // ===== GESTION DES UTILISATEURS ===== + users: router({ + list: adminProcedure.query(async () => { + return db.getAllUsers(); + }), + + getById: adminProcedure.input(z.object({ id: z.number() })).query(async ({ input }) => { + return db.getUserById(input.id); + }), + + update: adminProcedure.input(z.object({ + id: z.number(), + name: z.string().optional(), + email: z.string().email().optional(), + role: z.enum(["user", "admin"]).optional(), + isActive: z.boolean().optional(), + })).mutation(async ({ input }) => { + const { id, ...data } = input; + await db.updateUser(id, data); + return { success: true }; + }), + + toggleStatus: adminProcedure.input(z.object({ + id: z.number(), + isActive: z.boolean(), + })).mutation(async ({ input }) => { + await db.toggleUserStatus(input.id, input.isActive); + return { success: true }; + }), + + delete: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => { + await db.deleteUser(input.id); + return { success: true }; + }), + }), }); export type AppRouter = typeof appRouter; diff --git a/todo.md b/todo.md index 2838f19..54e894f 100644 --- a/todo.md +++ b/todo.md @@ -174,3 +174,17 @@ - [x] Implémenter la logique de comparaison public cible vs fonction apprenants - [x] Afficher les statistiques et écarts dans l'interface - [x] Tester le rapport avec différentes données + +## Gestion des utilisateurs de l'application + +- [x] Analyser le schéma actuel de la table users +- [x] Ajouter les champs nécessaires (statut actif/inactif, date de création compte) +- [x] Créer les procédures tRPC pour la gestion des utilisateurs (liste, création, modification, désactivation) +- [x] Créer la page AdminUsers avec tableau des utilisateurs +- [x] Implémenter le formulaire de création d'utilisateur +- [x] Implémenter le formulaire de modification d'utilisateur +- [x] Ajouter la fonctionnalité d'activation/désactivation des comptes +- [x] Ajouter des filtres de recherche (nom, email, rôle, statut) +- [x] Ajouter la gestion des rôles (admin, user) +- [x] Intégrer la page dans le menu de navigation +- [x] Tester toutes les fonctionnalités de gestion des utilisateurs