fix: corriger le flux de réinitialisation par e-mail
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m22s
Some checks failed
Validation applicative / TypeScript, tests et build (push) Failing after 2m22s
This commit is contained in:
@@ -38,8 +38,8 @@ 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(),
|
||||||
/** Email de l'utilisateur qui demande la réinitialisation */
|
/** Utilisateur propriétaire du token. Ce contrat correspond à la table déployée. */
|
||||||
email: varchar("email", { length: 320 }).notNull(),
|
userId: int("userId").notNull(),
|
||||||
/** Token unique généré pour la réinitialisation */
|
/** 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) */
|
/** Date d'expiration du token (24h après création) */
|
||||||
@@ -143,7 +143,8 @@ export const inscriptions = mysqlTable("inscriptions", {
|
|||||||
id: int("id").autoincrement().primaryKey(),
|
id: int("id").autoincrement().primaryKey(),
|
||||||
sequenceId: int("sequenceId").notNull(),
|
sequenceId: int("sequenceId").notNull(),
|
||||||
apprenantId: int("apprenantId").notNull(),
|
apprenantId: int("apprenantId").notNull(),
|
||||||
statut: mysqlEnum("statut", ["confirmee", "en_attente", "annulee"]).default("confirmee").notNull(),
|
/** `liste_attente` est la valeur historique et réellement déployée dans les trois environnements. */
|
||||||
|
statut: mysqlEnum("statut", ["confirmee", "liste_attente", "annulee"]).default("confirmee").notNull(),
|
||||||
dateInscription: timestamp("dateInscription").defaultNow().notNull(),
|
dateInscription: timestamp("dateInscription").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
});
|
});
|
||||||
@@ -190,10 +191,31 @@ export type InsertEmailConfig = typeof emailConfig.$inferInsert;
|
|||||||
/**
|
/**
|
||||||
* Table des templates d'emails
|
* Table des templates d'emails
|
||||||
*/
|
*/
|
||||||
|
export const emailTemplateTypes = [
|
||||||
|
"inscription",
|
||||||
|
"teaser",
|
||||||
|
"rappel",
|
||||||
|
"rappelJ1",
|
||||||
|
"rappel1",
|
||||||
|
"rappel2",
|
||||||
|
"rappel3",
|
||||||
|
"rappel4",
|
||||||
|
"rappel5",
|
||||||
|
"rappel6",
|
||||||
|
"reset_password",
|
||||||
|
"attestation",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type EmailTemplateType = (typeof emailTemplateTypes)[number];
|
||||||
|
|
||||||
|
export function isEmailTemplateType(value: string): value is EmailTemplateType {
|
||||||
|
return (emailTemplateTypes as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
export const emailTemplates = mysqlTable("emailTemplates", {
|
export const emailTemplates = mysqlTable("emailTemplates", {
|
||||||
id: int("id").autoincrement().primaryKey(),
|
id: int("id").autoincrement().primaryKey(),
|
||||||
/** Type de template (inscription, teaser, rappel1, rappel2, rappel3, rappel4, rappel5, rappel6, reset_password) */
|
/** Type de template (inscription, teaser, rappel1, rappel2, rappel3, rappel4, rappel5, rappel6, reset_password) */
|
||||||
type: mysqlEnum("type", ["inscription", "teaser", "rappel", "rappelJ1", "rappel1", "rappel2", "rappel3", "rappel4", "rappel5", "rappel6", "reset_password", "attestation"]).notNull().unique(),
|
type: mysqlEnum("type", emailTemplateTypes).notNull().unique(),
|
||||||
titre: varchar("titre", { length: 255 }).notNull(),
|
titre: varchar("titre", { length: 255 }).notNull(),
|
||||||
/** Corps du message avec variables dynamiques */
|
/** Corps du message avec variables dynamiques */
|
||||||
bodyContent: text("bodyContent").notNull(),
|
bodyContent: text("bodyContent").notNull(),
|
||||||
@@ -312,7 +334,13 @@ export const questionnaires = mysqlTable("questionnaires", {
|
|||||||
description: text("description"),
|
description: text("description"),
|
||||||
/** Type de questionnaire (satisfaction, evaluation_pre, evaluation_post) */
|
/** Type de questionnaire (satisfaction, evaluation_pre, evaluation_post) */
|
||||||
type: mysqlEnum("type", ["satisfaction", "evaluation_pre", "evaluation_post"]).notNull(),
|
type: mysqlEnum("type", ["satisfaction", "evaluation_pre", "evaluation_post"]).notNull(),
|
||||||
|
/** Formation ciblée ; null signifie que le questionnaire s'applique à toutes les formations. */
|
||||||
|
formationId: int("formationId"),
|
||||||
actif: boolean("actif").default(true).notNull(),
|
actif: boolean("actif").default(true).notNull(),
|
||||||
|
/** Active l'envoi automatique après la fin d'une séquence. */
|
||||||
|
envoiAutomatique: boolean("envoiAutomatique").default(false).notNull(),
|
||||||
|
/** Délai, en jours, entre la fin de séquence et l'envoi automatique. */
|
||||||
|
delaiEnvoiJours: int("delaiEnvoiJours").default(1).notNull(),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
});
|
});
|
||||||
@@ -335,9 +363,14 @@ export const questions = mysqlTable("questions", {
|
|||||||
valeurMin: int("valeurMin"),
|
valeurMin: int("valeurMin"),
|
||||||
/** Valeur maximale pour les échelles */
|
/** Valeur maximale pour les échelles */
|
||||||
valeurMax: int("valeurMax"),
|
valeurMax: int("valeurMax"),
|
||||||
|
/** Libellé affiché pour l'extrémité minimale de l'échelle. */
|
||||||
|
echelleLabelMin: varchar("echelleLabelMin", { length: 100 }),
|
||||||
|
/** Libellé affiché pour l'extrémité maximale de l'échelle. */
|
||||||
|
echelleLabelMax: varchar("echelleLabelMax", { length: 100 }),
|
||||||
obligatoire: boolean("obligatoire").default(false).notNull(),
|
obligatoire: boolean("obligatoire").default(false).notNull(),
|
||||||
ordre: int("ordre").notNull(),
|
ordre: int("ordre").notNull(),
|
||||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Question = typeof questions.$inferSelect;
|
export type Question = typeof questions.$inferSelect;
|
||||||
|
|||||||
51
server/__tests__/passwordResetEmailTemplate.test.ts
Normal file
51
server/__tests__/passwordResetEmailTemplate.test.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { replaceEmailVariables } from "../emailTemplateUtils";
|
||||||
|
|
||||||
|
const getEmailTemplateByType = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../db", () => ({ getEmailTemplateByType }));
|
||||||
|
|
||||||
|
const databaseHelpers = readFileSync(new URL("../db.ts", import.meta.url), "utf8");
|
||||||
|
const emailService = readFileSync(new URL("../emailService.ts", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
describe("modèle d’e-mail de réinitialisation", () => {
|
||||||
|
it("conserve un lien unique sous forme de variable dans le modèle par défaut", () => {
|
||||||
|
expect(databaseHelpers).toContain('type: "reset_password"');
|
||||||
|
expect(databaseHelpers).toContain("{{lienReinitialisation}}");
|
||||||
|
expect(emailService).toContain("lienReinitialisation: params.resetLink");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("substitue le lien de réinitialisation dans le contenu du modèle", () => {
|
||||||
|
const link = "https://formations.recette.santinova-soft.org/reset-password?token=abc123";
|
||||||
|
const result = replaceEmailVariables(
|
||||||
|
'<a href="{{lienReinitialisation}}">Réinitialiser</a>',
|
||||||
|
{ lienReinitialisation: link },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toContain(`href="${link}"`);
|
||||||
|
expect(result).not.toContain("{{lienReinitialisation}}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ajoute le contenu de secours lorsque le modèle existant ne contient pas de lien", async () => {
|
||||||
|
getEmailTemplateByType.mockResolvedValue({
|
||||||
|
type: "reset_password",
|
||||||
|
titre: "Réinitialisation",
|
||||||
|
bodyContent: "<p>Réinitialisation mot de passe</p>",
|
||||||
|
couleurPrincipale: "#283581",
|
||||||
|
couleurSecondaire: "#0578BE",
|
||||||
|
piedDePage: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { generateEmailFromTemplate } = await import("../emailTemplateGenerator");
|
||||||
|
const resetLink = "https://formations.recette.santinova-soft.org/reset-password?token=abc123";
|
||||||
|
const html = await generateEmailFromTemplate(
|
||||||
|
"reset_password",
|
||||||
|
`<a href="${resetLink}">Réinitialiser</a>`,
|
||||||
|
{ lienReinitialisation: resetLink },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("Réinitialisation mot de passe");
|
||||||
|
expect(html).toContain(`href="${resetLink}"`);
|
||||||
|
});
|
||||||
|
});
|
||||||
66
server/__tests__/passwordResetTokenContract.test.ts
Normal file
66
server/__tests__/passwordResetTokenContract.test.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getDb, createPasswordResetToken, getPasswordResetToken, markTokenAsUsed } from "../db";
|
||||||
|
import { passwordResetTokens, users } from "../../drizzle/schema";
|
||||||
|
|
||||||
|
const schema = readFileSync(
|
||||||
|
new URL("../../drizzle/schema.ts", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const databaseHelpers = readFileSync(
|
||||||
|
new URL("../db.ts", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const router = readFileSync(
|
||||||
|
new URL("../routers.ts", import.meta.url),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const passwordResetTokenSchema = schema.match(
|
||||||
|
/export const passwordResetTokens = mysqlTable\([\s\S]*?\n\}\);/,
|
||||||
|
)?.[0];
|
||||||
|
|
||||||
|
describe("contrat des tokens de réinitialisation", () => {
|
||||||
|
it("référence un utilisateur et non une colonne email absente de la table déployée", () => {
|
||||||
|
expect(passwordResetTokenSchema).toContain('userId: int("userId").notNull()');
|
||||||
|
expect(passwordResetTokenSchema).not.toContain('email: varchar("email", { length: 320 }).notNull()');
|
||||||
|
expect(databaseHelpers).toContain(
|
||||||
|
"createPasswordResetToken(userId: number, token: string)",
|
||||||
|
);
|
||||||
|
expect(databaseHelpers).toContain("userId,");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("utilise l’identifiant utilisateur dans les parcours public et administrateur", () => {
|
||||||
|
expect(router).toContain("db.createPasswordResetToken(utilisateur.id, token)");
|
||||||
|
expect(router).toContain("db.createPasswordResetToken(user.id, token)");
|
||||||
|
expect(router).toContain("db.getUserById(resetToken.userId)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ne génère plus de lien de réinitialisation sur un domaine fictif", () => {
|
||||||
|
expect(router).not.toContain("https://votre-domaine.com/reset-password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("insère et consomme un token lié à un utilisateur sans dépendre d’un e-mail", async () => {
|
||||||
|
const db = await getDb();
|
||||||
|
if (!db) throw new Error("Base de test indisponible");
|
||||||
|
|
||||||
|
const [user] = await db.select({ id: users.id }).from(users).limit(1);
|
||||||
|
if (!user) throw new Error("Utilisateur de test introuvable");
|
||||||
|
|
||||||
|
const token = `test-${randomUUID()}`;
|
||||||
|
try {
|
||||||
|
await createPasswordResetToken(user.id, token);
|
||||||
|
|
||||||
|
const created = await getPasswordResetToken(token);
|
||||||
|
expect(created?.userId).toBe(user.id);
|
||||||
|
expect(created?.used).toBe(false);
|
||||||
|
|
||||||
|
await markTokenAsUsed(created!.id);
|
||||||
|
const consumed = await getPasswordResetToken(token);
|
||||||
|
expect(consumed?.used).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await db.delete(passwordResetTokens).where(eq(passwordResetTokens.token, token));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
44
server/db.ts
44
server/db.ts
@@ -23,6 +23,8 @@ import {
|
|||||||
Apprenant,
|
Apprenant,
|
||||||
Formation,
|
Formation,
|
||||||
EmailTemplate,
|
EmailTemplate,
|
||||||
|
EmailTemplateType,
|
||||||
|
isEmailTemplateType,
|
||||||
InsertEmailTemplate,
|
InsertEmailTemplate,
|
||||||
formateurs,
|
formateurs,
|
||||||
InsertFormateur,
|
InsertFormateur,
|
||||||
@@ -451,7 +453,14 @@ export async function updateApprenant(id: number, data: Partial<InsertApprenant>
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) throw new Error("Database not available");
|
if (!db) throw new Error("Database not available");
|
||||||
|
|
||||||
await db.update(apprenants).set(data).where(eq(apprenants.id, id));
|
try {
|
||||||
|
await db.update(apprenants).set(data).where(eq(apprenants.id, id));
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.code === 'ER_DUP_ENTRY' || error?.message?.includes('Duplicate entry')) {
|
||||||
|
throw new Error("Cet email est déjà utilisé par un autre apprenant.");
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteApprenant(id: number) {
|
export async function deleteApprenant(id: number) {
|
||||||
@@ -676,22 +685,14 @@ export async function deleteUser(id: number) {
|
|||||||
|
|
||||||
// ==================== GESTION DES TOKENS DE RÉINITIALISATION ====================
|
// ==================== GESTION DES TOKENS DE RÉINITIALISATION ====================
|
||||||
|
|
||||||
export async function createPasswordResetToken(email: string, token: string) {
|
export async function createPasswordResetToken(userId: number, 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();
|
// Un Date est converti par Drizzle avec le fuseau du serveur sans manipulation de chaîne.
|
||||||
expiresAt.setHours(expiresAt.getHours() + 24); // Token valide 24h
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
// Convertir la date au format MySQL DATETIME (YYYY-MM-DD HH:MM:SS)
|
return db.insert(passwordResetTokens).values({ userId, token, expiresAt });
|
||||||
const mysqlDate = expiresAt.toISOString().slice(0, 19).replace('T', ' ');
|
|
||||||
|
|
||||||
// Utiliser une requête SQL brute pour éviter les problèmes avec Drizzle
|
|
||||||
const result = await db.execute(sql`
|
|
||||||
INSERT INTO passwordResetTokens (email, token, expiresAt)
|
|
||||||
VALUES (${email}, ${token}, ${mysqlDate})
|
|
||||||
`);
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPasswordResetToken(token: string) {
|
export async function getPasswordResetToken(token: string) {
|
||||||
@@ -745,6 +746,10 @@ export async function getEmailTemplateByType(type: string) {
|
|||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db) return null;
|
if (!db) return null;
|
||||||
|
|
||||||
|
// Les services de notification peuvent demander un template non persistant.
|
||||||
|
// Dans ce cas, le générateur retombe volontairement sur son HTML de secours.
|
||||||
|
if (!isEmailTemplateType(type)) return null;
|
||||||
|
|
||||||
const results = await db.select().from(emailTemplates).where(eq(emailTemplates.type, type)).limit(1);
|
const results = await db.select().from(emailTemplates).where(eq(emailTemplates.type, type)).limit(1);
|
||||||
return results.length > 0 ? results[0] : null;
|
return results.length > 0 ? results[0] : null;
|
||||||
}
|
}
|
||||||
@@ -786,6 +791,10 @@ export async function deleteEmailTemplate(type: 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");
|
||||||
|
|
||||||
|
if (!isEmailTemplateType(type)) {
|
||||||
|
throw new Error("Type de template email invalide");
|
||||||
|
}
|
||||||
|
|
||||||
await db.delete(emailTemplates).where(eq(emailTemplates.type, type));
|
await db.delete(emailTemplates).where(eq(emailTemplates.type, type));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -958,7 +967,14 @@ export async function initializeDefaultEmailTemplates() {
|
|||||||
{
|
{
|
||||||
type: "reset_password",
|
type: "reset_password",
|
||||||
titre: "Réinitialisation de mot de passe",
|
titre: "Réinitialisation de mot de passe",
|
||||||
bodyContent: "<p>Contenu par défaut</p>",
|
// Le lien doit rester une variable : chaque demande génère un token différent.
|
||||||
|
bodyContent: `<p>Bonjour {{prenomApprenant}} {{nomApprenant}},</p>
|
||||||
|
<p>Vous avez demandé la réinitialisation de votre mot de passe pour votre compte Manager Itinova.</p>
|
||||||
|
<p>Cliquez sur le bouton ci-dessous pour choisir un nouveau mot de passe :</p>
|
||||||
|
<p style="text-align: center;"><a href="{{lienReinitialisation}}" class="button">Réinitialiser mon mot de passe</a></p>
|
||||||
|
<p>Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :</p>
|
||||||
|
<p style="word-break: break-all;">{{lienReinitialisation}}</p>
|
||||||
|
<p>Ce lien est valide pendant 24 heures et ne peut être utilisé qu’une seule fois.</p>`,
|
||||||
couleurPrincipale: "#283581",
|
couleurPrincipale: "#283581",
|
||||||
couleurSecondaire: "#0578BE",
|
couleurSecondaire: "#0578BE",
|
||||||
piedDePage: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.",
|
piedDePage: "Cet email a été envoyé automatiquement par le système de gestion des formations Itinova. Pour toute question, veuillez contacter le service RH.",
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { sendEmail as sendEmailViaService, isEmailServiceConfigured } from "./_c
|
|||||||
import { generateEmailFromTemplate } from "./emailTemplateGenerator";
|
import { generateEmailFromTemplate } from "./emailTemplateGenerator";
|
||||||
import { EmailVariables } from "./emailTemplateUtils";
|
import { EmailVariables } from "./emailTemplateUtils";
|
||||||
|
|
||||||
|
type GroupEmailType = "teaser" | "rappel1" | "rappel2" | "rappel3" | "rappel4" | "rappel5" | "rappel6";
|
||||||
|
|
||||||
interface EmailParams {
|
interface EmailParams {
|
||||||
to: string;
|
to: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -612,10 +614,10 @@ export async function sendRappelJ1Email(params: {
|
|||||||
* Envoie des emails groupés à tous les inscrits d'une séquence
|
* Envoie des emails groupés à tous les inscrits d'une séquence
|
||||||
*/
|
*/
|
||||||
export async function sendGroupEmail(params: {
|
export async function sendGroupEmail(params: {
|
||||||
recipients: Array<{ email: string; prenom: string; nom: string; fonction: string }>;
|
recipients: Array<{ email: string; prenom: string; nom: string; fonction: string | null }>;
|
||||||
formationNom: string;
|
formationNom: string;
|
||||||
sequenceNom: string;
|
sequenceNom: string;
|
||||||
type: 'teaser' | 'rappel' | 'rappel_j1';
|
type: GroupEmailType;
|
||||||
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
|
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
|
||||||
lieu?: string;
|
lieu?: string;
|
||||||
formateur?: string;
|
formateur?: string;
|
||||||
@@ -630,37 +632,26 @@ export async function sendGroupEmail(params: {
|
|||||||
apprenantEmail: recipient.email,
|
apprenantEmail: recipient.email,
|
||||||
apprenantPrenom: recipient.prenom,
|
apprenantPrenom: recipient.prenom,
|
||||||
apprenantNom: recipient.nom,
|
apprenantNom: recipient.nom,
|
||||||
apprenantFonction: recipient.fonction,
|
apprenantFonction: recipient.fonction || '',
|
||||||
formationNom: params.formationNom,
|
formationNom: params.formationNom,
|
||||||
sequenceNom: params.sequenceNom,
|
sequenceNom: params.sequenceNom,
|
||||||
dates: params.dates,
|
dates: params.dates,
|
||||||
lieu: params.lieu,
|
lieu: params.lieu,
|
||||||
formateur: params.formateur,
|
formateur: params.formateur,
|
||||||
});
|
});
|
||||||
} else if (params.type === 'rappel') {
|
} else {
|
||||||
await sendRappelJ7Email({
|
// Un seul chemin pour les six rappels : le contenu est celui du template choisi.
|
||||||
|
await sendRappelEmail({
|
||||||
apprenantEmail: recipient.email,
|
apprenantEmail: recipient.email,
|
||||||
apprenantPrenom: recipient.prenom,
|
apprenantPrenom: recipient.prenom,
|
||||||
apprenantNom: recipient.nom,
|
apprenantNom: recipient.nom,
|
||||||
apprenantFonction: recipient.fonction,
|
apprenantFonction: recipient.fonction || '',
|
||||||
formationNom: params.formationNom,
|
formationNom: params.formationNom,
|
||||||
sequenceNom: params.sequenceNom,
|
sequenceNom: params.sequenceNom,
|
||||||
dates: params.dates,
|
dates: params.dates,
|
||||||
lieu: params.lieu || '',
|
lieu: params.lieu || '',
|
||||||
formateur: params.formateur,
|
formateur: params.formateur,
|
||||||
});
|
templateType: params.type,
|
||||||
} else {
|
|
||||||
// rappel_j1
|
|
||||||
await sendRappelJ1Email({
|
|
||||||
apprenantEmail: recipient.email,
|
|
||||||
apprenantPrenom: recipient.prenom,
|
|
||||||
apprenantNom: recipient.nom,
|
|
||||||
apprenantFonction: recipient.fonction,
|
|
||||||
formationNom: params.formationNom,
|
|
||||||
sequenceNom: params.sequenceNom,
|
|
||||||
dates: params.dates,
|
|
||||||
lieu: params.lieu,
|
|
||||||
formateur: params.formateur,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
sent++;
|
sent++;
|
||||||
@@ -714,7 +705,13 @@ export async function sendPasswordResetEmail(params: {
|
|||||||
return sendEmail({
|
return sendEmail({
|
||||||
to: params.apprenantEmail,
|
to: params.apprenantEmail,
|
||||||
subject: 'Réinitialisation de votre mot de passe - Manager Itinova',
|
subject: 'Réinitialisation de votre mot de passe - Manager Itinova',
|
||||||
html: await getEmailTemplate(content, 'reset_password'),
|
// Le modèle reset_password doit recevoir le lien : son bodyContent est personnalisable
|
||||||
|
// et ne doit jamais masquer l'action de réinitialisation.
|
||||||
|
html: await getEmailTemplate(content, 'reset_password', {
|
||||||
|
nomApprenant: params.apprenantNom,
|
||||||
|
prenomApprenant: params.apprenantPrenom,
|
||||||
|
lienReinitialisation: params.resetLink,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ function buildEmailHTML(template: EmailTemplate, content: string, variables?: Em
|
|||||||
// Utiliser bodyContent si disponible, sinon utiliser content (legacy)
|
// Utiliser bodyContent si disponible, sinon utiliser content (legacy)
|
||||||
let emailBody = template.bodyContent || content || '';
|
let emailBody = template.bodyContent || content || '';
|
||||||
|
|
||||||
|
// Les anciens modèles reset_password enregistrés avant l’ajout de la variable
|
||||||
|
// ne contiennent que du texte. On conserve ce texte, puis on ajoute le contenu
|
||||||
|
// métier (bouton et lien unique) afin que le destinataire puisse agir.
|
||||||
|
if (template.type === 'reset_password' && !emailBody.includes('{{lienReinitialisation}}')) {
|
||||||
|
emailBody = `${emailBody}\n${content}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Remplacer les variables si fournies
|
// Remplacer les variables si fournies
|
||||||
if (variables && emailBody) {
|
if (variables && emailBody) {
|
||||||
emailBody = replaceEmailVariables(emailBody, variables);
|
emailBody = replaceEmailVariables(emailBody, variables);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export interface EmailVariables {
|
|||||||
lieu?: string;
|
lieu?: string;
|
||||||
formateur?: string;
|
formateur?: string;
|
||||||
lienInscription?: string;
|
lienInscription?: string;
|
||||||
|
lienReinitialisation?: string;
|
||||||
[key: string]: string | undefined;
|
[key: string]: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +66,7 @@ export function generateEmailPreview(template: string): string {
|
|||||||
lieu: 'Salle de formation A - Bâtiment principal',
|
lieu: 'Salle de formation A - Bâtiment principal',
|
||||||
formateur: 'Jean Martin',
|
formateur: 'Jean Martin',
|
||||||
lienInscription: 'https://exemple.com/inscription/abc123',
|
lienInscription: 'https://exemple.com/inscription/abc123',
|
||||||
|
lienReinitialisation: 'https://exemple.com/reset-password?token=abc123',
|
||||||
};
|
};
|
||||||
|
|
||||||
return replaceEmailVariables(template, exampleVariables);
|
return replaceEmailVariables(template, exampleVariables);
|
||||||
|
|||||||
@@ -19,6 +19,23 @@ import { sql } from "drizzle-orm";
|
|||||||
import { catalogueRouter } from "./routers/catalogue";
|
import { catalogueRouter } from "./routers/catalogue";
|
||||||
import { planFormationRouter } from "./routers/planFormation";
|
import { planFormationRouter } from "./routers/planFormation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le lien de réinitialisation avec le domaine réellement utilisé.
|
||||||
|
* Cela permet au même code de fonctionner en local, recette et production.
|
||||||
|
*/
|
||||||
|
function buildPasswordResetUrl(req: { protocol: string; get: (name: string) => string | undefined }, token: string) {
|
||||||
|
const forwardedProtocol = req.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
||||||
|
const protocol = forwardedProtocol || req.protocol;
|
||||||
|
const forwardedHost = req.get("x-forwarded-host")?.split(",")[0]?.trim();
|
||||||
|
const host = forwardedHost || req.get("host");
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "URL de réinitialisation indisponible" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${protocol}://${host}/reset-password?token=${encodeURIComponent(token)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Procédure admin uniquement
|
// Procédure admin uniquement
|
||||||
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
|
||||||
if (ctx.user.role !== 'admin') {
|
if (ctx.user.role !== 'admin') {
|
||||||
@@ -52,7 +69,7 @@ export const appRouter = router({
|
|||||||
.input(z.object({
|
.input(z.object({
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
}))
|
}))
|
||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { email } = input;
|
const { email } = input;
|
||||||
|
|
||||||
// Vérifier si l'utilisateur existe
|
// Vérifier si l'utilisateur existe
|
||||||
@@ -66,10 +83,10 @@ export const appRouter = router({
|
|||||||
const token = crypto.randomBytes(32).toString('hex');
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
// Sauvegarder le token dans la base de données
|
// Sauvegarder le token dans la base de données
|
||||||
await db.createPasswordResetToken(email, token);
|
await db.createPasswordResetToken(utilisateur.id, token);
|
||||||
|
|
||||||
// Envoyer l'email avec le template reset_password
|
// Envoyer l'email avec le template reset_password
|
||||||
const resetUrl = `${process.env.VITE_OAUTH_PORTAL_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
|
const resetUrl = buildPasswordResetUrl(ctx.req, token);
|
||||||
|
|
||||||
await sendResetPasswordEmail({
|
await sendResetPasswordEmail({
|
||||||
email,
|
email,
|
||||||
@@ -117,7 +134,7 @@ export const appRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Récupérer l'utilisateur
|
// Récupérer l'utilisateur
|
||||||
const utilisateur = await db.getUtilisateurByEmail(resetToken.email);
|
const utilisateur = await db.getUserById(resetToken.userId);
|
||||||
if (!utilisateur) {
|
if (!utilisateur) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'NOT_FOUND',
|
code: 'NOT_FOUND',
|
||||||
@@ -667,7 +684,8 @@ export const appRouter = router({
|
|||||||
|
|
||||||
// Vérifier si la séquence est bloquée
|
// Vérifier si la séquence est bloquée
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) {
|
// Une date de blocage est optionnelle : ne jamais transformer `null` en 01/01/1970.
|
||||||
|
if (sequence.statut === 'bloquee' || (sequence.dateBlocage && now >= new Date(sequence.dateBlocage))) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
message: 'Les inscriptions sont fermées pour cette séquence (J-15 dépassé)'
|
message: 'Les inscriptions sont fermées pour cette séquence (J-15 dépassé)'
|
||||||
@@ -685,7 +703,7 @@ export const appRouter = router({
|
|||||||
|
|
||||||
// Vérifier la capacité
|
// Vérifier la capacité
|
||||||
const nbInscrits = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
|
const nbInscrits = await db.countInscriptionsBySequence(input.sequenceId, 'confirmee');
|
||||||
const statut = nbInscrits >= sequence.capaciteMax ? 'en_attente' : 'confirmee';
|
const statut = nbInscrits >= sequence.capaciteMax ? 'liste_attente' : 'confirmee';
|
||||||
|
|
||||||
await db.createInscription({
|
await db.createInscription({
|
||||||
apprenantId: input.apprenantId,
|
apprenantId: input.apprenantId,
|
||||||
@@ -785,7 +803,7 @@ export const appRouter = router({
|
|||||||
const admins = await db.getAdminUsers();
|
const admins = await db.getAdminUsers();
|
||||||
const adminEmails = admins.filter(a => a.email).map(a => a.email!);
|
const adminEmails = admins.filter(a => a.email).map(a => a.email!);
|
||||||
if (adminEmails.length > 0) {
|
if (adminEmails.length > 0) {
|
||||||
const nbListeAttente = await db.countInscriptionsBySequence(input.sequenceId, 'en_attente');
|
const nbListeAttente = await db.countInscriptionsBySequence(input.sequenceId, 'liste_attente');
|
||||||
let formateurNom: string | undefined;
|
let formateurNom: string | undefined;
|
||||||
if (inscriptionSequence.formateurId) {
|
if (inscriptionSequence.formateurId) {
|
||||||
const formateur = await db.getFormateurById(inscriptionSequence.formateurId);
|
const formateur = await db.getFormateurById(inscriptionSequence.formateurId);
|
||||||
@@ -850,7 +868,7 @@ export const appRouter = router({
|
|||||||
|
|
||||||
// Vérifier si la séquence est bloquée
|
// Vérifier si la séquence est bloquée
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
if (sequence.statut === 'bloquee' || now >= new Date(sequence.dateBlocage)) {
|
if (sequence.statut === 'bloquee' || (sequence.dateBlocage && now >= new Date(sequence.dateBlocage))) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
code: 'FORBIDDEN',
|
code: 'FORBIDDEN',
|
||||||
message: 'Les désinscriptions sont fermées pour cette séquence (J-15 dépassé)'
|
message: 'Les désinscriptions sont fermées pour cette séquence (J-15 dépassé)'
|
||||||
@@ -927,7 +945,7 @@ export const appRouter = router({
|
|||||||
// Notifier le premier en liste d'attente qu'une place s'est libérée
|
// Notifier le premier en liste d'attente qu'une place s'est libérée
|
||||||
const inscriptionsListeAttente = await db.getInscriptionsBySequence(input.sequenceId);
|
const inscriptionsListeAttente = await db.getInscriptionsBySequence(input.sequenceId);
|
||||||
const premierEnAttente = inscriptionsListeAttente
|
const premierEnAttente = inscriptionsListeAttente
|
||||||
.filter(i => i.inscription.statut === 'en_attente')
|
.filter(i => i.inscription.statut === 'liste_attente')
|
||||||
.sort((a, b) => new Date(a.inscription.dateInscription).getTime() - new Date(b.inscription.dateInscription).getTime())[0];
|
.sort((a, b) => new Date(a.inscription.dateInscription).getTime() - new Date(b.inscription.dateInscription).getTime())[0];
|
||||||
|
|
||||||
if (premierEnAttente && premierEnAttente.apprenant) {
|
if (premierEnAttente && premierEnAttente.apprenant) {
|
||||||
@@ -973,7 +991,7 @@ export const appRouter = router({
|
|||||||
|
|
||||||
updateStatut: adminProcedure.input(z.object({
|
updateStatut: adminProcedure.input(z.object({
|
||||||
id: z.number(),
|
id: z.number(),
|
||||||
statut: z.enum(['confirmee', 'en_attente', 'annulee']),
|
statut: z.enum(['confirmee', 'liste_attente', 'annulee']),
|
||||||
})).mutation(async ({ input }) => {
|
})).mutation(async ({ input }) => {
|
||||||
// Récupérer l'inscription avant modification
|
// Récupérer l'inscription avant modification
|
||||||
const inscriptionAvant = await db.getInscriptionById(input.id);
|
const inscriptionAvant = await db.getInscriptionById(input.id);
|
||||||
@@ -1293,7 +1311,7 @@ export const appRouter = router({
|
|||||||
await db.createInscription({
|
await db.createInscription({
|
||||||
apprenantId: input.apprenantId,
|
apprenantId: input.apprenantId,
|
||||||
sequenceId: input.sequenceId,
|
sequenceId: input.sequenceId,
|
||||||
statut: input.statut === "confirme" ? "confirmee" : "en_attente",
|
statut: input.statut === "confirme" ? "confirmee" : "liste_attente",
|
||||||
});
|
});
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -1384,7 +1402,7 @@ export const appRouter = router({
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
}),
|
}),
|
||||||
|
|
||||||
requestPasswordReset: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
|
requestPasswordReset: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input, ctx }) => {
|
||||||
const user = await db.getUserById(input.id);
|
const user = await db.getUserById(input.id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error("Utilisateur introuvable");
|
throw new Error("Utilisateur introuvable");
|
||||||
@@ -1399,11 +1417,10 @@ export const appRouter = router({
|
|||||||
const token = crypto.randomBytes(32).toString('hex');
|
const token = crypto.randomBytes(32).toString('hex');
|
||||||
|
|
||||||
// Sauvegarder le token en base (la fonction gère l'expiration automatiquement)
|
// Sauvegarder le token en base (la fonction gère l'expiration automatiquement)
|
||||||
await db.createPasswordResetToken(user.email, token);
|
await db.createPasswordResetToken(user.id, token);
|
||||||
|
|
||||||
// Générer le lien de réinitialisation
|
// Générer le lien de réinitialisation sur l’environnement réellement appelé.
|
||||||
// TODO: Remplacer par l'URL réelle de votre application en production
|
const resetLink = buildPasswordResetUrl(ctx.req, token);
|
||||||
const resetLink = `https://votre-domaine.com/reset-password?token=${token}`;
|
|
||||||
|
|
||||||
// Envoyer l'email
|
// Envoyer l'email
|
||||||
const emailService = await import('./emailService');
|
const emailService = await import('./emailService');
|
||||||
@@ -2057,8 +2074,8 @@ export const appRouter = router({
|
|||||||
texte: z.string(),
|
texte: z.string(),
|
||||||
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]),
|
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]),
|
||||||
options: z.string().optional(),
|
options: z.string().optional(),
|
||||||
echelleMin: z.number().optional(),
|
valeurMin: z.number().optional(),
|
||||||
echelleMax: z.number().optional(),
|
valeurMax: z.number().optional(),
|
||||||
echelleLabelMin: z.string().optional(),
|
echelleLabelMin: z.string().optional(),
|
||||||
echelleLabelMax: z.string().optional(),
|
echelleLabelMax: z.string().optional(),
|
||||||
obligatoire: z.boolean().default(false),
|
obligatoire: z.boolean().default(false),
|
||||||
@@ -2076,8 +2093,8 @@ export const appRouter = router({
|
|||||||
texte: z.string().optional(),
|
texte: z.string().optional(),
|
||||||
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]).optional(),
|
typeQuestion: z.enum(["choix_multiple", "echelle", "texte_libre", "oui_non"]).optional(),
|
||||||
options: z.string().optional(),
|
options: z.string().optional(),
|
||||||
echelleMin: z.number().optional(),
|
valeurMin: z.number().optional(),
|
||||||
echelleMax: z.number().optional(),
|
valeurMax: z.number().optional(),
|
||||||
echelleLabelMin: z.string().optional(),
|
echelleLabelMin: z.string().optional(),
|
||||||
echelleLabelMax: z.string().optional(),
|
echelleLabelMax: z.string().optional(),
|
||||||
obligatoire: z.boolean().optional(),
|
obligatoire: z.boolean().optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user