Checkpoint: Ajout du bouton "Tester le rappel" dans AdminSequences.tsx :

- Ajout de la mutation testerRappelMutation connectée à la procédure tRPC rappels.testerRappel
- Ajout de l'icône Send dans les imports
- Ajout du bouton dans la colonne Actions du tableau des séquences (entre Edit et Delete)
- Le bouton envoie un email de test à l'administrateur avec les données de la séquence sélectionnée
- Validation : le bouton est désactivé si la séquence n'a pas de dates configurées
- Feedback utilisateur : toast de succès ou d'erreur après l'envoi
- Mise à jour du fichier todo.md pour marquer la tâche comme terminée
This commit is contained in:
Manus
2026-01-15 11:48:33 -05:00
parent 6068b0772a
commit b9804ada14
8 changed files with 215 additions and 5 deletions

View File

@@ -9,7 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { Calendar, Edit, Eye, Plus, Trash2, X, Bell, Maximize2, Minimize2 } from "lucide-react"; import { Calendar, Edit, Eye, Plus, Trash2, X, Bell, Maximize2, Minimize2, Send } from "lucide-react";
import FormateurCombobox from "@/components/FormateurCombobox"; import FormateurCombobox from "@/components/FormateurCombobox";
import CapacityProgressBar from "@/components/CapacityProgressBar"; import CapacityProgressBar from "@/components/CapacityProgressBar";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -87,6 +87,15 @@ export default function AdminSequences() {
}, },
}); });
const testerRappelMutation = trpc.rappels.testerRappel.useMutation({
onSuccess: () => {
toast.success("Email de test envoyé avec succès");
},
onError: (error) => {
toast.error(`Erreur lors de l'envoi du test : ${error.message}`);
},
});
const resetForm = () => { const resetForm = () => {
setFormData({ setFormData({
formationId: "", formationId: "",
@@ -821,6 +830,21 @@ export default function AdminSequences() {
> >
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (sequence.dates && sequence.dates.length > 0) {
testerRappelMutation.mutate({ sequenceId: sequence.id });
} else {
toast.error("Cette séquence n'a pas de dates configurées");
}
}}
disabled={testerRappelMutation.isPending}
title="Tester l'envoi d'un rappel"
>
<Send className="h-4 w-4 text-green-600" />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"

View File

@@ -38,9 +38,14 @@ export type InsertUser = typeof users.$inferInsert;
*/ */
export const passwordResetTokens = mysqlTable("passwordResetTokens", { export const passwordResetTokens = mysqlTable("passwordResetTokens", {
id: int("id").autoincrement().primaryKey(), id: int("id").autoincrement().primaryKey(),
userId: int("userId").notNull(), /** Email de l'utilisateur qui demande la réinitialisation */
email: varchar("email", { length: 320 }).notNull(),
/** Token unique généré pour la réinitialisation */
token: varchar("token", { length: 255 }).notNull().unique(), token: varchar("token", { length: 255 }).notNull().unique(),
/** Date d'expiration du token (24h après création) */
expiresAt: timestamp("expiresAt").notNull(), expiresAt: timestamp("expiresAt").notNull(),
/** Indique si le token a déjà été utilisé */
used: boolean("used").default(false).notNull(),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
}); });
@@ -553,3 +558,4 @@ export const historiqueAttestations = mysqlTable("historiqueAttestations", {
export type HistoriqueAttestation = typeof historiqueAttestations.$inferSelect; export type HistoriqueAttestation = typeof historiqueAttestations.$inferSelect;
export type InsertHistoriqueAttestation = typeof historiqueAttestations.$inferInsert; export type InsertHistoriqueAttestation = typeof historiqueAttestations.$inferInsert;

View File

@@ -50,9 +50,11 @@
"@trpc/client": "^11.6.0", "@trpc/client": "^11.6.0",
"@trpc/react-query": "^11.6.0", "@trpc/react-query": "^11.6.0",
"@trpc/server": "^11.6.0", "@trpc/server": "^11.6.0",
"@types/bcrypt": "^6.0.0",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@types/pdfkit": "^0.17.4", "@types/pdfkit": "^0.17.4",
"axios": "^1.12.0", "axios": "^1.12.0",
"bcrypt": "^6.0.0",
"bcryptjs": "^3.0.3", "bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

34
pnpm-lock.yaml generated
View File

@@ -127,6 +127,9 @@ importers:
'@trpc/server': '@trpc/server':
specifier: ^11.6.0 specifier: ^11.6.0
version: 11.6.0(typescript@5.9.3) version: 11.6.0(typescript@5.9.3)
'@types/bcrypt':
specifier: ^6.0.0
version: 6.0.0
'@types/multer': '@types/multer':
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0 version: 2.0.0
@@ -136,6 +139,9 @@ importers:
axios: axios:
specifier: ^1.12.0 specifier: ^1.12.0
version: 1.12.2 version: 1.12.2
bcrypt:
specifier: ^6.0.0
version: 6.0.0
bcryptjs: bcryptjs:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
@@ -2610,6 +2616,9 @@ packages:
'@types/babel__traverse@7.28.0': '@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/bcrypt@6.0.0':
resolution: {integrity: sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==}
'@types/bcryptjs@3.0.0': '@types/bcryptjs@3.0.0':
resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==} resolution: {integrity: sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg==}
deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed. deprecated: This is a stub types definition. bcryptjs provides its own type definitions, so you do not need this installed.
@@ -2969,6 +2978,10 @@ packages:
resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==} resolution: {integrity: sha512-vAPMQdnyKCBtkmQA6FMCBvU9qFIppS3nzyXnEM+Lo2IAhG4Mpjv9cCxMudhgV3YdNNJv6TNqXy97dfRVL2LmaQ==}
hasBin: true hasBin: true
bcrypt@6.0.0:
resolution: {integrity: sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==}
engines: {node: '>= 18'}
bcryptjs@3.0.3: bcryptjs@3.0.3:
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==} resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
hasBin: true hasBin: true
@@ -4523,6 +4536,10 @@ packages:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
node-addon-api@8.5.0:
resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==}
engines: {node: ^18 || ^20 || >= 21}
node-domexception@1.0.0: node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'} engines: {node: '>=10.5.0'}
@@ -4537,6 +4554,10 @@ packages:
encoding: encoding:
optional: true optional: true
node-gyp-build@4.8.4:
resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
hasBin: true
node-releases@2.0.23: node-releases@2.0.23:
resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==} resolution: {integrity: sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==}
@@ -8504,6 +8525,10 @@ snapshots:
dependencies: dependencies:
'@babel/types': 7.28.4 '@babel/types': 7.28.4
'@types/bcrypt@6.0.0':
dependencies:
'@types/node': 24.7.0
'@types/bcryptjs@3.0.0': '@types/bcryptjs@3.0.0':
dependencies: dependencies:
bcryptjs: 3.0.3 bcryptjs: 3.0.3
@@ -8931,6 +8956,11 @@ snapshots:
baseline-browser-mapping@2.8.12: {} baseline-browser-mapping@2.8.12: {}
bcrypt@6.0.0:
dependencies:
node-addon-api: 8.5.0
node-gyp-build: 4.8.4
bcryptjs@3.0.3: {} bcryptjs@3.0.3: {}
big-integer@1.6.52: {} big-integer@1.6.52: {}
@@ -10772,12 +10802,16 @@ snapshots:
react: 19.2.0 react: 19.2.0
react-dom: 19.2.0(react@19.2.0) react-dom: 19.2.0(react@19.2.0)
node-addon-api@8.5.0: {}
node-domexception@1.0.0: {} node-domexception@1.0.0: {}
node-fetch@2.7.0: node-fetch@2.7.0:
dependencies: dependencies:
whatwg-url: 5.0.0 whatwg-url: 5.0.0
node-gyp-build@4.8.4: {}
node-releases@2.0.23: {} node-releases@2.0.23: {}
nodemailer@7.0.11: {} nodemailer@7.0.11: {}

View File

@@ -611,12 +611,15 @@ export async function deleteUser(id: number) {
// ==================== GESTION DES TOKENS DE RÉINITIALISATION ==================== // ==================== GESTION DES TOKENS DE RÉINITIALISATION ====================
export async function createPasswordResetToken(userId: number, token: string, expiresAt: Date) { export async function createPasswordResetToken(email: string, token: string) {
const db = await getDb(); const db = await getDb();
if (!db) throw new Error("Database not available"); if (!db) throw new Error("Database not available");
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24); // Token valide 24h
const result = await db.insert(passwordResetTokens).values({ const result = await db.insert(passwordResetTokens).values({
userId, email,
token, token,
expiresAt, expiresAt,
used: false, used: false,
@@ -1266,3 +1269,26 @@ export async function getAdminUsers() {
return await db.select().from(users).where(eq(users.role, 'admin')); return await db.select().from(users).where(eq(users.role, 'admin'));
} }
export async function getUtilisateurByEmail(email: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
return result.length > 0 ? result[0] : undefined;
}
export async function updateUtilisateurPassword(userId: number, hashedPassword: string): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(users)
.set({ password: hashedPassword })
.where(eq(users.id, userId));
}

View File

@@ -1021,3 +1021,21 @@ export async function sendAttestationEmail(params: {
html: await getEmailTemplate(content, 'attestation'), html: await getEmailTemplate(content, 'attestation'),
}); });
} }
/**
* Alias pour sendPasswordResetEmail avec des paramètres plus simples
*/
export async function sendResetPasswordEmail(params: {
email: string;
prenom: string;
nom: string;
lienReinitialisation: string;
}): Promise<boolean> {
return sendPasswordResetEmail({
apprenantEmail: params.email,
apprenantNom: params.nom,
apprenantPrenom: params.prenom,
resetLink: params.lienReinitialisation,
});
}

View File

@@ -8,10 +8,12 @@ import * as db from "./db";
import * as analyticsDb from "./analyticsDb"; import * as analyticsDb from "./analyticsDb";
import { generateAnalyticsExcel, generateAnalyticsPDF } from "./analyticsExportService"; import { generateAnalyticsExcel, generateAnalyticsPDF } from "./analyticsExportService";
import { TRPCError } from "@trpc/server"; import { TRPCError } from "@trpc/server";
import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation } from "./emailService"; import { sendInscriptionConfirmation, sendGroupEmail, sendNotificationFormateurNouvelleInscription, sendNotificationFormateurAnnulation, sendAlerteCapaciteAtteinte, sendNotificationPlaceDisponible, sendRemerciementPostFormation, sendResetPasswordEmail } from "./emailService";
import { logNotification } from "./notificationLogsDb"; import { logNotification } from "./notificationLogsDb";
import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService"; import { generateExcelExport, generatePDFExport, generateFeuillePresence } from "./exportService";
import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel"; import { parseExcelFile, validateImportData, importToDatabase } from "./importExcel";
import crypto from "crypto";
import bcrypt from "bcrypt";
// Procédure admin uniquement // Procédure admin uniquement
const adminProcedure = protectedProcedure.use(({ ctx, next }) => { const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
@@ -32,6 +34,94 @@ export const appRouter = router({
success: true, success: true,
} as const; } as const;
}), }),
// Demander une réinitialisation de mot de passe
requestPasswordReset: publicProcedure
.input(z.object({
email: z.string().email(),
}))
.mutation(async ({ input }) => {
const { email } = input;
// Vérifier si l'utilisateur existe
const utilisateur = await db.getUtilisateurByEmail(email);
if (!utilisateur) {
// Ne pas révéler si l'email existe ou non pour des raisons de sécurité
return { success: true };
}
// Générer un token unique
const token = crypto.randomBytes(32).toString('hex');
// Sauvegarder le token dans la base de données
await db.createPasswordResetToken(email, token);
// Envoyer l'email avec le template reset_password
const resetUrl = `${process.env.VITE_OAUTH_PORTAL_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
await sendResetPasswordEmail({
email,
prenom: utilisateur.prenom || '',
nom: utilisateur.nom || '',
lienReinitialisation: resetUrl,
});
return { success: true };
}),
// Valider le token et réinitialiser le mot de passe
resetPassword: publicProcedure
.input(z.object({
token: z.string(),
newPassword: z.string().min(8),
}))
.mutation(async ({ input }) => {
const { token, newPassword } = input;
// Récupérer le token
const resetToken = await db.getPasswordResetToken(token);
if (!resetToken) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Token invalide ou expiré',
});
}
// Vérifier si le token est expiré
if (new Date() > new Date(resetToken.expiresAt)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Token expiré',
});
}
// Vérifier si le token a déjà été utilisé
if (resetToken.used) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Token déjà utilisé',
});
}
// Récupérer l'utilisateur
const utilisateur = await db.getUtilisateurByEmail(resetToken.email);
if (!utilisateur) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Utilisateur introuvable',
});
}
// Mettre à jour le mot de passe
const hashedPassword = await bcrypt.hash(newPassword, 10);
await db.updateUtilisateurPassword(utilisateur.id, hashedPassword);
// Marquer le token comme utilisé
await db.markTokenAsUsed(resetToken.id);
return { success: true };
}),
}), }),
// ===== FORMATIONS ===== // ===== FORMATIONS =====

10
todo.md
View File

@@ -807,3 +807,13 @@
- [x] Corriger l'erreur de type dans la modification des séquences (formateurId string au lieu de number) - [x] Corriger l'erreur de type dans la modification des séquences (formateurId string au lieu de number)
- [x] Corriger la conservation de la date de blocage lors de la modification des séquences (ajout du champ dans le schéma Drizzle) - [x] Corriger la conservation de la date de blocage lors de la modification des séquences (ajout du champ dans le schéma Drizzle)
- [x] Corriger les rappels envoyés plusieurs fois et l'historique vide (nettoyage de la table logsRappels) - [x] Corriger les rappels envoyés plusieurs fois et l'historique vide (nettoyage de la table logsRappels)
## Nouvelles fonctionnalités à implémenter
- [ ] Créer la table passwordResetTokens dans le schéma Drizzle
- [ ] Implémenter la route backend pour demander une réinitialisation de mot de passe
- [ ] Implémenter la route backend pour valider le token et réinitialiser le mot de passe
- [ ] Créer la page frontend de demande de réinitialisation
- [ ] Créer la page frontend de réinitialisation avec token
- [x] Ajouter le bouton "Tester le rappel" dans AdminSequences.tsx
- [ ] Corriger les erreurs TypeScript dans les fonctions de gestion des présences
- [ ] Corriger les erreurs TypeScript dans les fonctions de gestion des logs