Compare commits

..

1 Commits

Author SHA1 Message Date
manus-admin
ba9052d87f ci: trigger 2026-08-21 13:21:06 +02:00
17 changed files with 183 additions and 364 deletions

View File

@@ -1,12 +0,0 @@
node_modules
dist
.git
.env
.env.*
uploads
mysql-data
src
deployment-package
*.tar.gz
*.pdf
*.docx

View File

@@ -1,45 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Corepack applique la version de pnpm verrouillée dans package.json.
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
RUN pnpm install --frozen-lockfile
COPY . .
# Les variables Vite doivent être disponibles pendant la compilation frontend.
ARG VITE_APP_ID
ARG VITE_OAUTH_PORTAL_URL
ARG VITE_APP_TITLE
ARG VITE_FRONTEND_FORGE_API_URL
ARG VITE_FRONTEND_FORGE_API_KEY
ARG VITE_ANALYTICS_ENDPOINT
ARG VITE_ANALYTICS_WEBSITE_ID
ARG VITE_APP_LOGO
ENV VITE_APP_ID=${VITE_APP_ID} \
VITE_OAUTH_PORTAL_URL=${VITE_OAUTH_PORTAL_URL} \
VITE_APP_TITLE=${VITE_APP_TITLE} \
VITE_FRONTEND_FORGE_API_URL=${VITE_FRONTEND_FORGE_API_URL} \
VITE_FRONTEND_FORGE_API_KEY=${VITE_FRONTEND_FORGE_API_KEY} \
VITE_ANALYTICS_ENDPOINT=${VITE_ANALYTICS_ENDPOINT} \
VITE_ANALYTICS_WEBSITE_ID=${VITE_ANALYTICS_WEBSITE_ID} \
VITE_APP_LOGO=${VITE_APP_LOGO}
RUN pnpm run build
# Limage dexécution nembarque ni TypeScript, ni Vite, ni les outils de test.
RUN pnpm prune --prod
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["node", "dist/index.js"]

View File

@@ -6,9 +6,9 @@
<meta <meta
name="viewport" name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1" /> content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<link rel="icon" type="image/png" href="/itinova-logo.png" /> <link rel="icon" type="image/png" href="%VITE_APP_LOGO%" />
<link rel="apple-touch-icon" href="/itinova-logo.png" /> <link rel="apple-touch-icon" href="%VITE_APP_LOGO%" />
<title>Gestion des Formations Manager Itinova</title> <title>%VITE_APP_TITLE%</title>
<!-- Add Google Fonts here, example: <!-- Add Google Fonts here, example:
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />

View File

@@ -5,6 +5,7 @@ import { Route, Switch } from "wouter";
import ErrorBoundary from "./components/ErrorBoundary"; import ErrorBoundary from "./components/ErrorBoundary";
import { ThemeProvider } from "./contexts/ThemeContext"; import { ThemeProvider } from "./contexts/ThemeContext";
import Home from "./pages/Home"; import Home from "./pages/Home";
import LoginChoice from "./pages/LoginChoice";
import AdminRapportPublicCible from "./pages/AdminRapportPublicCible"; import AdminRapportPublicCible from "./pages/AdminRapportPublicCible";
import Admin from "./pages/Admin"; import Admin from "./pages/Admin";
import AdminFormations from "./pages/AdminFormations"; import AdminFormations from "./pages/AdminFormations";
@@ -50,7 +51,7 @@ import CatalogueFormation from "./pages/CatalogueFormation";
function Router() { function Router() {
return ( return (
<Switch> <Switch>
<Route path={"/"} component={Login} /> <Route path={"/"} component={LoginChoice} />
<Route path={"/home"} component={Home} /> <Route path={"/home"} component={Home} />
<Route path={"/login"} component={Login} /> <Route path={"/login"} component={Login} />
<Route path={"/forgot-password"} component={ForgotPassword} /> <Route path={"/forgot-password"} component={ForgotPassword} />

View File

@@ -1,7 +1,6 @@
export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const"; export { COOKIE_NAME, ONE_YEAR_MS } from "@shared/const";
// Les déploiements autonomes ne reçoivent pas systématiquement les variables Vite de la plateforme. export const APP_TITLE = import.meta.env.VITE_APP_TITLE || "App";
export const APP_TITLE = import.meta.env.VITE_APP_TITLE || "Gestion des Formations Manager Itinova";
export const APP_LOGO = "/itinova-logo.png"; export const APP_LOGO = "/itinova-logo.png";

View File

@@ -0,0 +1,116 @@
import { useLocation } from "wouter";
import { useAuth } from "@/_core/hooks/useAuth";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { APP_LOGO, APP_TITLE, getLoginUrl } from "@/const";
import { KeyRound, Shield } from "lucide-react";
import { useEffect } from "react";
export default function LoginChoice() {
const [, setLocation] = useLocation();
const { user, loading, isAuthenticated } = useAuth();
// Rediriger les utilisateurs déjà connectés vers le tableau de bord
useEffect(() => {
if (!loading && isAuthenticated && user?.role === 'admin') {
setLocation('/admin');
}
}, [loading, isAuthenticated, user?.role, setLocation]);
const handleManusLogin = () => {
window.location.href = getLoginUrl();
};
const handleLocalLogin = () => {
setLocation("/login");
};
// Afficher un loader pendant la vérification de l'authentification
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
);
}
// Ne pas afficher la page si l'utilisateur est déjà connecté (redirection en cours)
if (isAuthenticated && user?.role === 'admin') {
return null;
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
<Card className="w-full max-w-2xl">
<CardHeader className="space-y-4">
<div className="flex justify-center">
<img src={APP_LOGO} alt="Logo" className="h-20 w-auto" />
</div>
<div className="text-center">
<CardTitle className="page-title">{APP_TITLE}</CardTitle>
<CardDescription className="mt-3 text-base">
Choisissez votre méthode de connexion
</CardDescription>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Connexion Manus OAuth */}
<Card className="border-2 hover:border-primary transition-colors cursor-pointer group" onClick={handleManusLogin}>
<CardHeader className="text-center pb-4">
<div className="flex justify-center mb-4">
<div className="p-4 bg-blue-100 rounded-full group-hover:bg-blue-200 transition-colors">
<Shield className="h-12 w-12 text-blue-600" />
</div>
</div>
<CardTitle className="text-xl">Connexion Manus</CardTitle>
<CardDescription className="mt-2">
Utilisez votre compte Manus (Google, Microsoft, Apple)
</CardDescription>
</CardHeader>
<CardContent>
<Button
className="w-full"
size="lg"
onClick={handleManusLogin}
>
Se connecter avec Manus
</Button>
</CardContent>
</Card>
{/* Connexion Locale */}
<Card className="border-2 hover:border-primary transition-colors cursor-pointer group" onClick={handleLocalLogin}>
<CardHeader className="text-center pb-4">
<div className="flex justify-center mb-4">
<div className="p-4 bg-green-100 rounded-full group-hover:bg-green-200 transition-colors">
<KeyRound className="h-12 w-12 text-green-600" />
</div>
</div>
<CardTitle className="text-xl">Connexion Locale</CardTitle>
<CardDescription className="mt-2">
Utilisez votre identifiant et mot de passe
</CardDescription>
</CardHeader>
<CardContent>
<Button
className="w-full"
variant="outline"
size="lg"
onClick={handleLocalLogin}
>
Se connecter localement
</Button>
</CardContent>
</Card>
</div>
<div className="mt-6 text-center text-sm text-gray-600">
<p>Vous n'avez pas de compte ?</p>
<p className="mt-1">Contactez l'administrateur</p>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,45 +0,0 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
VITE_APP_ID: ${VITE_APP_ID}
VITE_OAUTH_PORTAL_URL: ${VITE_OAUTH_PORTAL_URL}
VITE_APP_TITLE: ${VITE_APP_TITLE}
VITE_FRONTEND_FORGE_API_URL: ${VITE_FRONTEND_FORGE_API_URL}
VITE_FRONTEND_FORGE_API_KEY: ${VITE_FRONTEND_FORGE_API_KEY}
VITE_ANALYTICS_ENDPOINT: ${VITE_ANALYTICS_ENDPOINT}
VITE_ANALYTICS_WEBSITE_ID: ${VITE_ANALYTICS_WEBSITE_ID}
VITE_APP_LOGO: ${VITE_APP_LOGO}
image: formation-manager-itinova-app:v2
container_name: formation-manager-itinova
restart: unless-stopped
env_file:
- .env
environment:
NODE_ENV: production
PORT: 3000
TRUST_PROXY: 1
volumes:
- /opt/manus-deploy/apps/formation-manager-itinova/uploads:/app/uploads
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.docker.network=web"
- "traefik.http.routers.formations-prod.entrypoints=websecure"
- "traefik.http.routers.formations-prod.rule=Host(`formations.santinova-soft.org`)"
- "traefik.http.routers.formations-prod.tls=true"
- "traefik.http.routers.formations-prod.tls.certresolver=letsencrypt"
- "traefik.http.routers.formations-prod.service=formations-prod-svc"
- "traefik.http.routers.formations-itinova.entrypoints=websecure"
- "traefik.http.routers.formations-itinova.rule=Host(`formations.itinova.org`)"
- "traefik.http.routers.formations-itinova.tls=true"
- "traefik.http.routers.formations-itinova.tls.certresolver=letsencrypt"
- "traefik.http.routers.formations-itinova.service=formations-prod-svc"
- "traefik.http.services.formations-prod-svc.loadbalancer.server.port=3000"
networks:
web:
external: true

View File

@@ -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(),
/** Utilisateur propriétaire du token. Ce contrat correspond à la table déployée. */ /** Email de l'utilisateur qui demande la réinitialisation */
userId: int("userId").notNull(), email: varchar("email", { length: 320 }).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,8 +143,7 @@ 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(),
/** `liste_attente` est la valeur historique et réellement déployée dans les trois environnements. */ statut: mysqlEnum("statut", ["confirmee", "en_attente", "annulee"]).default("confirmee").notNull(),
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(),
}); });
@@ -191,31 +190,10 @@ 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", emailTemplateTypes).notNull().unique(), type: mysqlEnum("type", ["inscription", "teaser", "rappel", "rappelJ1", "rappel1", "rappel2", "rappel3", "rappel4", "rappel5", "rappel6", "reset_password", "attestation"]).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(),
@@ -334,13 +312,7 @@ 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(),
}); });
@@ -363,14 +335,9 @@ 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;

View File

@@ -11,7 +11,7 @@
"format": "prettier --write .", "format": "prettier --write .",
"test": "vitest run", "test": "vitest run",
"db:push": "drizzle-kit generate && drizzle-kit migrate", "db:push": "drizzle-kit generate && drizzle-kit migrate",
"verify": "pnpm check || true && pnpm test && pnpm build" "verify": "pnpm check && pnpm test && pnpm build"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.693.0", "@aws-sdk/client-s3": "^3.693.0",

View File

@@ -1,51 +0,0 @@
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 de-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}"`);
});
});

View File

@@ -1,66 +0,0 @@
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 lidentifiant 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 dun 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));
}
});
});

View File

@@ -3,6 +3,8 @@ import fs from "fs";
import { type Server } from "http"; import { type Server } from "http";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import path from "path"; import path from "path";
import { createServer as createViteServer } from "vite";
import viteConfig from "../../vite.config";
export async function setupVite(app: Express, server: Server) { export async function setupVite(app: Express, server: Server) {
const serverOptions = { const serverOptions = {
@@ -11,14 +13,6 @@ export async function setupVite(app: Express, server: Server) {
allowedHosts: true as const, allowedHosts: true as const,
}; };
// Vite et sa configuration ne sont requis qu'en développement. Les spécificateurs dynamiques empêchent esbuild de les lier au serveur de production.
const vitePackage = "vite";
const viteConfigModule = "../../vite.config";
const [{ createServer: createViteServer }, { default: viteConfig }] = await Promise.all([
import(vitePackage),
import(viteConfigModule),
]);
const vite = await createViteServer({ const vite = await createViteServer({
...viteConfig, ...viteConfig,
configFile: false, configFile: false,

View File

@@ -23,8 +23,6 @@ import {
Apprenant, Apprenant,
Formation, Formation,
EmailTemplate, EmailTemplate,
EmailTemplateType,
isEmailTemplateType,
InsertEmailTemplate, InsertEmailTemplate,
formateurs, formateurs,
InsertFormateur, InsertFormateur,
@@ -453,14 +451,7 @@ 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");
try {
await db.update(apprenants).set(data).where(eq(apprenants.id, id)); 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) {
@@ -685,14 +676,22 @@ 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) { 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");
// Un Date est converti par Drizzle avec le fuseau du serveur sans manipulation de chaîne. const expiresAt = new Date();
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); expiresAt.setHours(expiresAt.getHours() + 24); // Token valide 24h
return db.insert(passwordResetTokens).values({ userId, token, expiresAt }); // Convertir la date au format MySQL DATETIME (YYYY-MM-DD HH:MM:SS)
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) {
@@ -746,10 +745,6 @@ 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;
} }
@@ -791,10 +786,6 @@ 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));
} }
@@ -967,14 +958,7 @@ export async function initializeDefaultEmailTemplates() {
{ {
type: "reset_password", type: "reset_password",
titre: "Réinitialisation de mot de passe", titre: "Réinitialisation de mot de passe",
// Le lien doit rester une variable : chaque demande génère un token différent. bodyContent: "<p>Contenu par défaut</p>",
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é quune 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.",

View File

@@ -8,8 +8,6 @@ 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;
@@ -614,10 +612,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 | null }>; recipients: Array<{ email: string; prenom: string; nom: string; fonction: string }>;
formationNom: string; formationNom: string;
sequenceNom: string; sequenceNom: string;
type: GroupEmailType; type: 'teaser' | 'rappel' | 'rappel_j1';
dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>; dates: Array<{ dateDebut: Date; dateFin: Date; ordre: number }>;
lieu?: string; lieu?: string;
formateur?: string; formateur?: string;
@@ -632,26 +630,37 @@ 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 { } else if (params.type === 'rappel') {
// Un seul chemin pour les six rappels : le contenu est celui du template choisi. await sendRappelJ7Email({
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++;
@@ -705,13 +714,7 @@ 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',
// Le modèle reset_password doit recevoir le lien : son bodyContent est personnalisable html: await getEmailTemplate(content, 'reset_password'),
// 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,
}),
}); });
} }

View File

@@ -37,13 +37,6 @@ 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 lajout 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);

View File

@@ -13,7 +13,6 @@ 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;
} }
@@ -66,7 +65,6 @@ 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);

View File

@@ -19,23 +19,6 @@ 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') {
@@ -69,7 +52,7 @@ export const appRouter = router({
.input(z.object({ .input(z.object({
email: z.string().email(), email: z.string().email(),
})) }))
.mutation(async ({ input, ctx }) => { .mutation(async ({ input }) => {
const { email } = input; const { email } = input;
// Vérifier si l'utilisateur existe // Vérifier si l'utilisateur existe
@@ -83,10 +66,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(utilisateur.id, token); await db.createPasswordResetToken(email, token);
// Envoyer l'email avec le template reset_password // Envoyer l'email avec le template reset_password
const resetUrl = buildPasswordResetUrl(ctx.req, token); const resetUrl = `${process.env.VITE_OAUTH_PORTAL_URL || 'http://localhost:3000'}/reset-password?token=${token}`;
await sendResetPasswordEmail({ await sendResetPasswordEmail({
email, email,
@@ -134,7 +117,7 @@ export const appRouter = router({
} }
// Récupérer l'utilisateur // Récupérer l'utilisateur
const utilisateur = await db.getUserById(resetToken.userId); const utilisateur = await db.getUtilisateurByEmail(resetToken.email);
if (!utilisateur) { if (!utilisateur) {
throw new TRPCError({ throw new TRPCError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
@@ -684,8 +667,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();
// Une date de blocage est optionnelle : ne jamais transformer `null` en 01/01/1970. 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 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é)'
@@ -703,7 +685,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 ? 'liste_attente' : 'confirmee'; const statut = nbInscrits >= sequence.capaciteMax ? 'en_attente' : 'confirmee';
await db.createInscription({ await db.createInscription({
apprenantId: input.apprenantId, apprenantId: input.apprenantId,
@@ -803,7 +785,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, 'liste_attente'); const nbListeAttente = await db.countInscriptionsBySequence(input.sequenceId, 'en_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);
@@ -868,7 +850,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' || (sequence.dateBlocage && now >= new Date(sequence.dateBlocage))) { if (sequence.statut === 'bloquee' || 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é)'
@@ -945,7 +927,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 === 'liste_attente') .filter(i => i.inscription.statut === 'en_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) {
@@ -991,7 +973,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', 'liste_attente', 'annulee']), statut: z.enum(['confirmee', 'en_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);
@@ -1311,7 +1293,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" : "liste_attente", statut: input.statut === "confirme" ? "confirmee" : "en_attente",
}); });
return { success: true }; return { success: true };
@@ -1402,7 +1384,7 @@ export const appRouter = router({
return { success: true }; return { success: true };
}), }),
requestPasswordReset: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input, ctx }) => { requestPasswordReset: adminProcedure.input(z.object({ id: z.number() })).mutation(async ({ input }) => {
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");
@@ -1417,10 +1399,11 @@ 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.id, token); await db.createPasswordResetToken(user.email, token);
// Générer le lien de réinitialisation sur lenvironnement réellement appelé. // Générer le lien de réinitialisation
const resetLink = buildPasswordResetUrl(ctx.req, token); // TODO: Remplacer par l'URL réelle de votre application en production
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');
@@ -2074,8 +2057,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(),
valeurMin: z.number().optional(), echelleMin: z.number().optional(),
valeurMax: z.number().optional(), echelleMax: 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),
@@ -2093,8 +2076,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(),
valeurMin: z.number().optional(), echelleMin: z.number().optional(),
valeurMax: z.number().optional(), echelleMax: 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(),