diff --git a/backend/prisma/migrations/20260914193115_add_must_change_password/migration.sql b/backend/prisma/migrations/20260914193115_add_must_change_password/migration.sql new file mode 100644 index 0000000..c25e8e3 --- /dev/null +++ b/backend/prisma/migrations/20260914193115_add_must_change_password/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "mustChangePassword" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/migrations/20260914220000_normalize_user_emails/migration.sql b/backend/prisma/migrations/20260914220000_normalize_user_emails/migration.sql new file mode 100644 index 0000000..d8c051e --- /dev/null +++ b/backend/prisma/migrations/20260914220000_normalize_user_emails/migration.sql @@ -0,0 +1,14 @@ +-- Les emails sont désormais stockés et recherchés en minuscules, sans espaces +-- autour (voir src/validations/email.validation.js). On aligne les comptes +-- existants, sauf ceux dont la version normalisée appartient déjà à un autre +-- compte : les modifier violerait la contrainte d'unicité et ferait échouer le +-- déploiement. Ces doublons éventuels sont à traiter à la main. +UPDATE "User" AS u +SET "email" = lower(trim(u."email")) +WHERE u."email" <> lower(trim(u."email")) + AND NOT EXISTS ( + SELECT 1 + FROM "User" AS other + WHERE other."id" <> u."id" + AND lower(trim(other."email")) = lower(trim(u."email")) + ); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 89737cd..822956c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -51,6 +51,11 @@ model User { resetPasswordToken String? resetPasswordExpires DateTime? + // Compte employé créé par un admin avec un mot de passe provisoire envoyé + // par email : tant qu'il vaut true, seul le choix d'un nouveau mot de passe + // est autorisé (voir auth.middleware.js). + mustChangePassword Boolean @default(false) + provider AuthProvider @default(LOCAL) providerId String? avatarUrl String? diff --git a/backend/prisma/seed.js b/backend/prisma/seed.js index 370608d..eaffa64 100644 --- a/backend/prisma/seed.js +++ b/backend/prisma/seed.js @@ -1,4 +1,5 @@ const prisma = require("../src/config/prisma"); +const { DEFAULT_START_DATE } = require("../src/utils/gameWindow"); async function main() { await prisma.lot.deleteMany(); @@ -42,8 +43,8 @@ async function main() { const existingSettings = await prisma.gameSettings.findFirst(); if (!existingSettings) { - await prisma.gameSettings.create({ data: {} }); - console.log("Date de début du jeu-concours initialisée à aujourd'hui."); + await prisma.gameSettings.create({ data: { startDate: DEFAULT_START_DATE } }); + console.log("Date de début du jeu-concours initialisée au 2 octobre 2026."); } else { console.log("Date de début du jeu-concours déjà définie, inchangée."); } diff --git a/backend/src/config/passport.js b/backend/src/config/passport.js index b9e2903..e69fe73 100644 --- a/backend/src/config/passport.js +++ b/backend/src/config/passport.js @@ -5,6 +5,7 @@ const crypto = require("crypto"); const prisma = require("./prisma"); const transporter = require("./mailer"); const getFrontendUrl = require("../utils/frontendUrl"); +const { normalizeEmail } = require("../validations/email.validation"); const PROVIDER_LABELS = { GOOGLE: "Google", FACEBOOK: "Facebook" }; @@ -58,7 +59,7 @@ function verifyOAuthProfile(provider) { return async (accessToken, refreshToken, profile, done) => { try { - const email = profile.emails?.[0]?.value; + const email = normalizeEmail(profile.emails?.[0]?.value); // Cas fréquent chez Facebook : compte ouvert avec un numéro de téléphone. if (!email) { diff --git a/backend/src/controllers/admin.controller.js b/backend/src/controllers/admin.controller.js index ecca247..93410e1 100644 --- a/backend/src/controllers/admin.controller.js +++ b/backend/src/controllers/admin.controller.js @@ -2,7 +2,8 @@ const bcrypt = require("bcrypt"); const crypto = require("crypto"); const prisma = require("../config/prisma"); const transporter = require("../config/mailer"); -const { getGameWindow } = require("../utils/gameWindow"); +const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow"); +const getFrontendUrl = require("../utils/frontendUrl"); const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"]; @@ -166,9 +167,30 @@ const getEmailingContacts = async (req, res) => { } }; +// Sans caractères ambigus (0/O, 1/l/I) : l'employé peut devoir le recopier. +const TEMP_PASSWORD_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"; +// Aucun caractère à échapper dans l'email HTML (&, <, >, guillemets). +const TEMP_PASSWORD_SPECIALS = "!@#$%*?"; +const TEMP_PASSWORD_LENGTH = 12; + +// Respecte la règle commune des mots de passe (au moins un caractère spécial), +// placé à une position aléatoire. +function generateTempPassword() { + const chars = []; + for (let i = 0; i < TEMP_PASSWORD_LENGTH - 1; i++) { + chars.push(TEMP_PASSWORD_ALPHABET[crypto.randomInt(TEMP_PASSWORD_ALPHABET.length)]); + } + const special = TEMP_PASSWORD_SPECIALS[crypto.randomInt(TEMP_PASSWORD_SPECIALS.length)]; + chars.splice(crypto.randomInt(chars.length + 1), 0, special); + return chars.join(""); +} + +// L'admin ne choisit pas le mot de passe de l'employé : on en génère un +// provisoire, envoyé uniquement par email, que l'employé devra remplacer à +// sa première connexion (mustChangePassword). const createEmployee = async (req, res) => { try { - const { firstname, lastname, email, password, birthdate, gender } = req.body; + const { firstname, lastname, email, birthdate, gender } = req.body; const existingUser = await prisma.user.findUnique({ where: { email }, @@ -180,7 +202,8 @@ const createEmployee = async (req, res) => { }); } - const hashedPassword = await bcrypt.hash(password, 10); + const tempPassword = generateTempPassword(); + const hashedPassword = await bcrypt.hash(tempPassword, 10); const employee = await prisma.user.create({ data: { @@ -192,6 +215,8 @@ const createEmployee = async (req, res) => { gender, role: "EMPLOYEE", marketingOptIn: false, + isEmailVerified: true, + mustChangePassword: true, }, select: { id: true, @@ -203,8 +228,45 @@ const createEmployee = async (req, res) => { }, }); + const loginUrl = `${getFrontendUrl()}/connexion`; + + try { + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: email, + subject: "Votre compte employé Thé Tip Top", + html: ` +
+

Bienvenue ${firstname} !

+

Un compte employé Thé Tip Top vient d'être créé pour vous.

+

Voici vos identifiants de première connexion :

+

+ Email : ${email}
+ Mot de passe provisoire : ${tempPassword} +

+

+ + Me connecter + +

+

Dès votre première connexion, vous devrez + choisir un nouveau mot de passe. Le mot de passe provisoire ne fonctionnera + plus ensuite.

+
+ `, + }); + } catch (mailError) { + // Sans l'email, l'employé n'a aucun moyen de connaître son mot de passe : + // on annule la création pour que l'admin puisse simplement réessayer. + console.error("Erreur envoi email employé:", mailError); + await prisma.user.delete({ where: { id: employee.id } }); + return res.status(502).json({ + message: "L'email d'invitation n'a pas pu être envoyé : le compte n'a pas été créé. Réessayez.", + }); + } + return res.status(201).json({ - message: "Employé créé avec succès.", + message: "Employé créé. Un email avec un mot de passe provisoire lui a été envoyé.", employee, }); } catch (error) { @@ -427,11 +489,137 @@ const getGrandPrizeWinner = async (req, res) => { } }; +const PERSON_SELECT = { id: true, firstname: true, lastname: true, email: true }; + +// Comptes employés, avec le nombre de lots que chacun a remis. `q` filtre sur +// le prénom, le nom ou l'email. +const getEmployees = async (req, res) => { + try { + const q = typeof req.query.q === "string" ? req.query.q.trim() : ""; + const contains = { contains: q, mode: "insensitive" }; + + const employees = await prisma.user.findMany({ + where: { + role: "EMPLOYEE", + ...(q && { OR: [{ firstname: contains }, { lastname: contains }, { email: contains }] }), + }, + select: { + ...PERSON_SELECT, + createdAt: true, + mustChangePassword: true, + _count: { select: { claimsValidated: { where: { status: "DELIVERED" } } } }, + }, + orderBy: [{ lastname: "asc" }, { firstname: "asc" }], + }); + + return res.json({ + employees: employees.map(({ _count, ...employee }) => ({ + ...employee, + deliveredCount: _count.claimsValidated, + })), + }); + } catch (error) { + console.error("Erreur liste employés:", error); + return res.status(500).json({ message: "Erreur serveur lors de la récupération des employés." }); + } +}; + +// Lots remis par un employé, du plus récent au plus ancien. +const getEmployeeDeliveries = async (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + return res.status(400).json({ message: "Identifiant d'employé invalide." }); + } + + const employee = await prisma.user.findFirst({ + where: { id, role: "EMPLOYEE" }, + select: PERSON_SELECT, + }); + + if (!employee) { + return res.status(404).json({ message: "Employé introuvable." }); + } + + const claims = await prisma.claim.findMany({ + where: { validatedByEmployeeId: id, status: "DELIVERED" }, + orderBy: { claimedAt: "desc" }, + include: { + participation: { + include: { + ticket: { select: { code: true } }, + lot: { select: { name: true, value: true } }, + user: { select: PERSON_SELECT }, + }, + }, + }, + }); + + return res.json({ + employee, + deliveries: claims.map((claim) => ({ + claimId: claim.id, + ticketCode: claim.participation.ticket.code, + lot: claim.participation.lot, + client: claim.participation.user, + deliveredAt: claim.claimedAt, + })), + }); + } catch (error) { + console.error("Erreur remises employé:", error); + return res.status(500).json({ message: "Erreur serveur lors de la récupération des remises." }); + } +}; + +// Traçabilité d'un ticket : qui l'a joué, quel lot, et quel employé l'a remis +// et quand. `status` vaut NOT_PLAYED tant qu'aucun client ne l'a saisi. +const getDeliveryByTicketCode = async (req, res) => { + try { + const code = req.params.code.trim().toUpperCase(); + const gameWindow = await getGameWindow(); + + const ticket = await prisma.ticket.findUnique({ + where: { code }, + include: { + lot: { select: { name: true, value: true } }, + participation: { + include: { + user: { select: PERSON_SELECT }, + claim: { include: { validatedByEmployee: { select: PERSON_SELECT } } }, + }, + }, + }, + }); + + if (!ticket) { + return res.status(404).json({ message: "Aucun ticket ne correspond à ce numéro." }); + } + + const claim = ticket.participation?.claim; + + return res.json({ + ticketCode: ticket.code, + lot: ticket.lot, + status: claim ? effectiveClaimStatus(claim.status, gameWindow) : "NOT_PLAYED", + playedAt: ticket.usedAt, + client: ticket.participation?.user ?? null, + deliveredBy: claim?.validatedByEmployee ?? null, + deliveredAt: claim?.status === "DELIVERED" ? claim.claimedAt : null, + }); + } catch (error) { + console.error("Erreur recherche remise:", error); + return res.status(500).json({ message: "Erreur serveur lors de la recherche du ticket." }); + } +}; + module.exports = { getStats, getUsers, getEmailingContacts, createEmployee, + getEmployees, + getEmployeeDeliveries, + getDeliveryByTicketCode, drawWinner, getGrandPrizeWinner, setGameSettings, diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index cfc12bb..171108a 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -4,6 +4,8 @@ const crypto = require("crypto"); const prisma = require("../config/prisma"); const transporter = require("../config/mailer"); const getFrontendUrl = require("../utils/frontendUrl"); +const { passwordRuleError } = require("../validations/password.validation"); +const { normalizeEmail } = require("../validations/email.validation"); const register = async (req, res) => { try { @@ -211,6 +213,7 @@ const login = async (req, res) => { lastname: user.lastname, email: user.email, role: user.role, + mustChangePassword: user.mustChangePassword, }, }); } catch (error) { @@ -221,9 +224,47 @@ const login = async (req, res) => { } }; +const RESET_LINK_EXPIRY_MS = 1000 * 60 * 30; // 30 min + +// Enregistre un jeton de réinitialisation (qui remplace le précédent) et envoie +// le lien par email. Partagé par « mot de passe oublié » et par le profil. +async function sendPasswordResetEmail(user) { + const resetPasswordToken = crypto.randomBytes(32).toString("hex"); + + await prisma.user.update({ + where: { id: user.id }, + data: { + resetPasswordToken, + resetPasswordExpires: new Date(Date.now() + RESET_LINK_EXPIRY_MS), + }, + }); + + // Le lien doit ouvrir le formulaire du front, pas la route POST de l'API : + // un clic depuis un email est une requête GET, qui tombait en 404. + const resetUrl = `${getFrontendUrl()}/definir-mot-de-passe/${resetPasswordToken}`; + + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: user.email, + subject: "Réinitialisation de votre mot de passe Thé Tip Top", + html: ` +

Réinitialisation du mot de passe

+

Bonjour ${user.firstname},

+

Vous avez demandé à définir un nouveau mot de passe.

+

Cliquez sur le lien ci-dessous pour le choisir :

+ Choisir mon nouveau mot de passe +

Ce lien expire dans 30 minutes.

+

Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.

+ `, + }); +} + +const FORGOT_PASSWORD_MESSAGE = + "Si un compte existe avec cet email, un lien de réinitialisation vient de lui être envoyé."; + const forgotPassword = async (req, res) => { try { - const { email } = req.body; + const email = normalizeEmail(req.body.email); if (!email) { return res.status(400).json({ @@ -237,45 +278,12 @@ const forgotPassword = async (req, res) => { // Sécurité : ne pas dire si l'email existe ou non if (!user) { - return res.status(200).json({ - message: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.", - }); + return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE }); } - const resetPasswordToken = crypto.randomBytes(32).toString("hex"); - const resetPasswordExpires = new Date(Date.now() + 1000 * 60 * 30); // 30 min + await sendPasswordResetEmail(user); - await prisma.user.update({ - where: { id: user.id }, - data: { - resetPasswordToken, - resetPasswordExpires, - }, - }); - - // Le lien doit ouvrir le formulaire du front, pas la route POST de l'API : - // un clic depuis un email est une requête GET, qui tombait en 404. - const frontendUrl = getFrontendUrl(); - const resetUrl = `${frontendUrl}/definir-mot-de-passe/${resetPasswordToken}`; - - await transporter.sendMail({ - from: process.env.MAIL_FROM, - to: user.email, - subject: "Réinitialisation de votre mot de passe Thé Tip Top", - html: ` -

Réinitialisation du mot de passe

-

Bonjour ${user.firstname},

-

Vous avez demandé la réinitialisation de votre mot de passe.

-

Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe :

- Réinitialiser mon mot de passe -

Ce lien expire dans 30 minutes.

-

Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.

- `, - }); - - return res.status(200).json({ - message: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.", - }); + return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE }); } catch (error) { console.error("Erreur forgotPassword:", error); return res.status(500).json({ @@ -284,15 +292,27 @@ const forgotPassword = async (req, res) => { } }; +// Profil connecté (compte Google sans mot de passe) : même lien, envoyé à +// l'adresse du compte. Authentifié, donc sans captcha. +const sendPasswordSetupLink = async (req, res) => { + try { + const user = await prisma.user.findUnique({ where: { id: req.user.id } }); + await sendPasswordResetEmail(user); + return res.json({ message: `Lien envoyé à ${user.email}.` }); + } catch (error) { + console.error("Erreur sendPasswordSetupLink:", error); + return res.status(500).json({ message: "Erreur serveur lors de l'envoi du lien." }); + } +}; + const resetPassword = async (req, res) => { try { const { token } = req.params; const { password, confirmPassword } = req.body; - if (!password || password.length < 8) { - return res.status(400).json({ - message: "Le nouveau mot de passe doit contenir au moins 8 caractères.", - }); + const passwordError = passwordRuleError(password); + if (passwordError) { + return res.status(400).json({ message: passwordError }); } // La confirmation est vérifiée côté serveur aussi : le contrôle du @@ -326,6 +346,14 @@ const resetPassword = async (req, res) => { password: hashedPassword, resetPasswordToken: null, resetPasswordExpires: null, + // Le lien reçu par email prouve l'accès à la boîte : le mot de passe + // choisi ici remplace aussi un éventuel mot de passe provisoire, et + // l'adresse est considérée comme validée (sinon la connexion qui suit + // serait refusée pour email non vérifié). + mustChangePassword: false, + isEmailVerified: true, + emailVerifyToken: null, + emailVerifyExpires: null, }, }); @@ -342,7 +370,8 @@ const resetPassword = async (req, res) => { const verifyCode = async (req, res) => { try { - const { email, code } = req.body; + const { code } = req.body; + const email = normalizeEmail(req.body.email); if (!email || !code) { return res.status(400).json({ message: "Email et code requis." }); @@ -424,6 +453,7 @@ const oauthCallback = (req, res) => { lastname: user.lastname, email: user.email, role: user.role, + mustChangePassword: user.mustChangePassword, }) ).toString("base64"); @@ -491,6 +521,47 @@ const deleteAccount = async (req, res) => { } }; +// Première connexion d'un employé : remplace le mot de passe provisoire reçu +// par email. L'utilisateur vient de s'authentifier avec ce mot de passe, on ne +// le redemande donc pas. +const setInitialPassword = async (req, res) => { + try { + const { password, confirmPassword } = req.body; + + if (!req.user.mustChangePassword) { + return res.status(400).json({ message: "Votre mot de passe a déjà été défini." }); + } + + const passwordError = passwordRuleError(password); + if (passwordError) { + return res.status(400).json({ message: passwordError }); + } + + if (password !== confirmPassword) { + return res.status(400).json({ message: "Les deux mots de passe ne correspondent pas." }); + } + + const user = await prisma.user.findUnique({ where: { id: req.user.id } }); + + if (user.password && (await bcrypt.compare(password, user.password))) { + return res.status(400).json({ + message: "Choisissez un mot de passe différent du mot de passe provisoire.", + }); + } + + const hashed = await bcrypt.hash(password, 10); + await prisma.user.update({ + where: { id: user.id }, + data: { password: hashed, mustChangePassword: false }, + }); + + return res.json({ message: "Mot de passe enregistré." }); + } catch (error) { + console.error("Erreur setInitialPassword:", error); + return res.status(500).json({ message: "Erreur serveur lors de l'enregistrement du mot de passe." }); + } +}; + const changePassword = async (req, res) => { try { const { currentPassword, newPassword } = req.body; @@ -500,8 +571,9 @@ const changePassword = async (req, res) => { return res.status(400).json({ message: "Les deux mots de passe sont requis." }); } - if (newPassword.length < 8) { - return res.status(400).json({ message: "Le nouveau mot de passe doit faire au moins 8 caractères." }); + const passwordError = passwordRuleError(newPassword); + if (passwordError) { + return res.status(400).json({ message: passwordError }); } const user = await prisma.user.findUnique({ where: { id: userId } }); @@ -536,7 +608,9 @@ module.exports = { verifyCode, resetPassword, forgotPassword, + sendPasswordSetupLink, changePassword, + setInitialPassword, deleteAccount, oauthCallback, }; \ No newline at end of file diff --git a/backend/src/controllers/game.controller.js b/backend/src/controllers/game.controller.js index 9725af2..b7593d6 100644 --- a/backend/src/controllers/game.controller.js +++ b/backend/src/controllers/game.controller.js @@ -185,7 +185,20 @@ const myGains = async (req, res) => { } }; +// Dates publiques du jeu-concours, affichées sur la page d'accueil : elles +// suivent la date de début fixée par l'admin. +const getPublicGameWindow = async (req, res) => { + try { + const { configured, phase, startDate, participationDeadline, claimDeadline } = await getGameWindow(); + return res.json({ configured, phase, startDate, participationDeadline, claimDeadline }); + } catch (error) { + console.error("Erreur lecture dates du jeu:", error); + return res.status(500).json({ message: "Erreur serveur lors de la lecture des dates du jeu-concours." }); + } +}; + module.exports = { participate, myGains, + getPublicGameWindow, }; \ No newline at end of file diff --git a/backend/src/middlewares/auth.middleware.js b/backend/src/middlewares/auth.middleware.js index 072501e..c661a5e 100644 --- a/backend/src/middlewares/auth.middleware.js +++ b/backend/src/middlewares/auth.middleware.js @@ -1,6 +1,11 @@ const jwt = require("jsonwebtoken"); const prisma = require("../config/prisma"); +// Seules routes accessibles tant qu'un mot de passe provisoire n'a pas été +// remplacé : sans ce filtre côté serveur, il suffirait d'appeler l'API +// directement pour contourner la page du front. +const ALLOWED_WHILE_PASSWORD_CHANGE_REQUIRED = ["/api/auth/me", "/api/auth/initial-password"]; + const authMiddleware = async (req, res, next) => { try { const authHeader = req.headers.authorization; @@ -25,6 +30,7 @@ const authMiddleware = async (req, res, next) => { role: true, provider: true, password: true, + mustChangePassword: true, }, }); @@ -34,6 +40,14 @@ const authMiddleware = async (req, res, next) => { }); } + const route = `${req.baseUrl}${req.path}`.replace(/\/+$/, ""); + if (user.mustChangePassword && !ALLOWED_WHILE_PASSWORD_CHANGE_REQUIRED.includes(route)) { + return res.status(403).json({ + message: "Vous devez choisir un nouveau mot de passe avant de continuer.", + code: "PASSWORD_CHANGE_REQUIRED", + }); + } + // req.user est renvoyé tel quel par /api/auth/me : on remplace le hash par // un simple booléen, qui suffit au profil pour proposer « définir » ou // « modifier » son mot de passe. diff --git a/backend/src/routes/admin.routes.js b/backend/src/routes/admin.routes.js index acd0e54..43427db 100644 --- a/backend/src/routes/admin.routes.js +++ b/backend/src/routes/admin.routes.js @@ -4,6 +4,9 @@ const { getUsers, getEmailingContacts, createEmployee, + getEmployees, + getEmployeeDeliveries, + getDeliveryByTicketCode, drawWinner, getGrandPrizeWinner, setGameSettings, @@ -81,6 +84,73 @@ router.get("/emailing", getEmailingContacts); */ router.post("/employees", validate(createEmployeeSchema), createEmployee); +/** + * @swagger + * /api/admin/employees: + * get: + * summary: Liste des comptes employés avec leur nombre de lots remis + * tags: + * - Admin + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: q + * schema: + * type: string + * description: Filtre sur le prénom, le nom ou l'email + * responses: + * 200: + * description: Employés + */ +router.get("/employees", getEmployees); + +/** + * @swagger + * /api/admin/employees/{id}/deliveries: + * get: + * summary: Lots remis par un employé + * tags: + * - Admin + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Employé et liste de ses remises (ticket, lot, client, date) + * 404: + * description: Employé introuvable + */ +router.get("/employees/:id/deliveries", getEmployeeDeliveries); + +/** + * @swagger + * /api/admin/deliveries/{code}: + * get: + * summary: Traçabilité d'un ticket (lot, client, employé ayant remis le lot, date) + * tags: + * - Admin + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: code + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Statut du ticket (NOT_PLAYED, PENDING, DELIVERED, CANCELLED) et remise éventuelle + * 404: + * description: Aucun ticket ne correspond à ce numéro + */ +router.get("/deliveries/:code", getDeliveryByTicketCode); + /** * @swagger * /api/admin/draw: diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index 33cf984..dcb3ed8 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -1,5 +1,5 @@ const express = require("express"); -const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller"); +const { register, login, verifyEmail, verifyCode, forgotPassword, sendPasswordSetupLink, resetPassword, me, changePassword, setInitialPassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller"); const authMiddleware = require("../middlewares/auth.middleware"); const validate = require("../middlewares/validate.middleware"); const verifyCaptcha = require("../middlewares/captcha.middleware"); @@ -113,16 +113,36 @@ router.get("/verify-email/:token",verifyEmail); * type: string * format: email * example: jean.dupont@gmail.com + * captchaToken: + * type: string + * description: Jeton Cloudflare Turnstile (exigé si TURNSTILE_SECRET_KEY est configurée) * responses: * 200: * description: Si un compte existe avec cet email, un lien de réinitialisation a été envoyé. * 400: - * description: Email requis. + * description: Email requis, ou vérification anti-robot manquante. * 500: * description: Erreur serveur. */ -router.post("/forgot-password", forgotPassword); +// Page publique : le captcha empêche d'envoyer des emails en masse vers +// n'importe quelle adresse. +router.post("/forgot-password", verifyCaptcha, forgotPassword); + +/** + * @swagger + * /api/auth/password-setup-link: + * post: + * summary: Envoie à l'utilisateur connecté un lien pour définir son mot de passe + * tags: + * - Auth + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Lien envoyé à l'adresse du compte. + */ +router.post("/password-setup-link", authMiddleware, sendPasswordSetupLink); /** * @swagger * /api/auth/reset-password/{token}: @@ -162,6 +182,37 @@ router.post("/reset-password/:token", resetPassword); router.post("/verify-code", verifyCode); router.patch("/change-password", authMiddleware, changePassword); + +/** + * @swagger + * /api/auth/initial-password: + * post: + * summary: Remplace le mot de passe provisoire d'un employé à sa première connexion + * tags: + * - Auth + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - password + * - confirmPassword + * properties: + * password: + * type: string + * confirmPassword: + * type: string + * responses: + * 200: + * description: Mot de passe enregistré, l'accès complet est rétabli. + * 400: + * description: Mot de passe invalide, non confirmé, identique au provisoire ou déjà défini. + */ +router.post("/initial-password", authMiddleware, setInitialPassword); router.delete("/account", authMiddleware, deleteAccount); /** diff --git a/backend/src/routes/game.routes.js b/backend/src/routes/game.routes.js index 69c4eaa..64a338e 100644 --- a/backend/src/routes/game.routes.js +++ b/backend/src/routes/game.routes.js @@ -1,5 +1,5 @@ const express = require("express"); -const { participate, myGains } = require("../controllers/game.controller"); +const { participate, myGains, getPublicGameWindow } = require("../controllers/game.controller"); const authMiddleware = require("../middlewares/auth.middleware"); const roleMiddleware = require("../middlewares/role.middleware"); const validate = require("../middlewares/validate.middleware"); @@ -41,4 +41,17 @@ router.post("/participate", authMiddleware, roleMiddleware("CLIENT"), validate(p router.get("/my-gains", authMiddleware, roleMiddleware("CLIENT"), myGains); +/** + * @swagger + * /api/game/window: + * get: + * summary: Dates publiques du jeu-concours (début, fin des participations, fin des retraits) + * tags: + * - Game + * responses: + * 200: + * description: Phase et échéances courantes ; dates nulles si le jeu n'est pas configuré + */ +router.get("/window", getPublicGameWindow); + module.exports = router; \ No newline at end of file diff --git a/backend/src/utils/gameWindow.js b/backend/src/utils/gameWindow.js index 65c19c1..47a9fbd 100644 --- a/backend/src/utils/gameWindow.js +++ b/backend/src/utils/gameWindow.js @@ -11,6 +11,11 @@ const PHASES = { CLOSED: "CLOSED", }; +// Date de lancement prévue par le règlement : du 2 au 31 octobre 2026 (heure +// de Paris). Utilisée pour initialiser la base ; l'admin peut la corriger +// depuis son tableau de bord. +const DEFAULT_START_DATE = new Date("2026-10-02T00:00:00+02:00"); + const GAME_DURATION_DAYS = 30; const CLAIM_EXTENSION_DAYS = 30; const DAY_MS = 24 * 60 * 60 * 1000; @@ -95,6 +100,7 @@ module.exports = { effectiveClaimStatus, participationClosedMessage, PHASES, + DEFAULT_START_DATE, GAME_DURATION_DAYS, CLAIM_EXTENSION_DAYS, }; diff --git a/backend/src/validations/admin.validation.js b/backend/src/validations/admin.validation.js index c6771bf..3d47820 100644 --- a/backend/src/validations/admin.validation.js +++ b/backend/src/validations/admin.validation.js @@ -1,10 +1,11 @@ const { z } = require("zod"); +const { emailSchema } = require("./email.validation"); const createEmployeeSchema = z.object({ firstname: z.string().min(2), lastname: z.string().min(2), - email: z.string().email(), - password: z.string().min(8), + // Pas de mot de passe : il est généré côté serveur (voir createEmployee). + email: emailSchema, birthdate: z.string().optional(), gender: z.string().optional(), }); diff --git a/backend/src/validations/auth.validation.js b/backend/src/validations/auth.validation.js index 4061a73..e8477e5 100644 --- a/backend/src/validations/auth.validation.js +++ b/backend/src/validations/auth.validation.js @@ -1,17 +1,27 @@ const { z } = require("zod"); +const { passwordSchema } = require("./password.validation"); +const { emailSchema } = require("./email.validation"); -const registerSchema = z.object({ - firstname: z.string().min(2, "Le prénom doit contenir au moins 2 caractères"), - lastname: z.string().min(2, "Le nom doit contenir au moins 2 caractères"), - email: z.string().email("Email invalide"), - password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"), - birthdate: z.string().optional(), - gender: z.string().optional(), - marketingOptIn: z.boolean().optional(), -}); +const registerSchema = z + .object({ + firstname: z.string().min(2, "Le prénom doit contenir au moins 2 caractères"), + lastname: z.string().min(2, "Le nom doit contenir au moins 2 caractères"), + email: emailSchema, + password: passwordSchema, + // Vérifiée aussi côté serveur : le contrôle du navigateur ne protège pas + // un appel direct à l'API. + confirmPassword: z.string({ error: "Veuillez confirmer le mot de passe" }), + birthdate: z.string().optional(), + gender: z.string().optional(), + marketingOptIn: z.boolean().optional(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Les deux mots de passe ne correspondent pas", + path: ["confirmPassword"], + }); const loginSchema = z.object({ - email: z.string().email("Email invalide"), + email: emailSchema, password: z.string().min(1, "Mot de passe requis"), }); diff --git a/backend/src/validations/email.validation.js b/backend/src/validations/email.validation.js new file mode 100644 index 0000000..715789c --- /dev/null +++ b/backend/src/validations/email.validation.js @@ -0,0 +1,11 @@ +const { z } = require("zod"); + +// Les emails sont stockés et recherchés en minuscules, sans espaces autour : +// « Jean@Exemple.fr » et « jean@exemple.fr » désignent le même compte. +function normalizeEmail(email) { + return typeof email === "string" ? email.trim().toLowerCase() : email; +} + +const emailSchema = z.string().trim().toLowerCase().email("Email invalide"); + +module.exports = { emailSchema, normalizeEmail }; diff --git a/backend/src/validations/password.validation.js b/backend/src/validations/password.validation.js new file mode 100644 index 0000000..a04e1bf --- /dev/null +++ b/backend/src/validations/password.validation.js @@ -0,0 +1,21 @@ +const { z } = require("zod"); + +// Règle de mot de passe, reprise à l'identique par le front +// (frontend/src/utils/passwordRules.js) : plus de 7 caractères et au moins un +// caractère spécial, c'est-à-dire ni lettre (accentuée comprise), ni chiffre, +// ni espace. +const SPECIAL_CHARACTER = /[^\p{L}\p{N}\s]/u; + +const passwordSchema = z + .string() + .min(8, "Le mot de passe doit contenir au moins 8 caractères") + .regex(SPECIAL_CHARACTER, "Le mot de passe doit contenir au moins un caractère spécial"); + +// Pour les contrôleurs qui valident à la main : message de la première règle +// non respectée, ou null si le mot de passe est valide. +function passwordRuleError(password) { + const result = passwordSchema.safeParse(password ?? ""); + return result.success ? null : `${result.error.issues[0].message}.`; +} + +module.exports = { passwordSchema, passwordRuleError, SPECIAL_CHARACTER }; diff --git a/backend/tests/adminDeliveries.test.js b/backend/tests/adminDeliveries.test.js new file mode 100644 index 0000000..16198b8 --- /dev/null +++ b/backend/tests/adminDeliveries.test.js @@ -0,0 +1,144 @@ +const request = require("supertest"); +const bcrypt = require("bcrypt"); +const jwt = require("jsonwebtoken"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) })); + +const PREFIX = "tracabilite-"; +const CODES = ["TRACE00001", "TRACE00002", "TRACE00003", "TRACE00004"]; +const ctx = {}; + +const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" }); +const asAdmin = (req) => req.set("Authorization", `Bearer ${ctx.tAdmin}`); + +async function cleanup() { + const users = await prisma.user.findMany({ where: { email: { startsWith: PREFIX } } }); + const ids = users.map((u) => u.id); + const participations = await prisma.participation.findMany({ where: { userId: { in: ids } } }); + await prisma.claim.deleteMany({ where: { participationId: { in: participations.map((p) => p.id) } } }); + await prisma.participation.deleteMany({ where: { userId: { in: ids } } }); + await prisma.ticket.deleteMany({ where: { code: { in: CODES } } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); +} + +// Ticket joué par le client, avec une réclamation éventuellement remise. +async function jouer(code, { remisPar, remisLe } = {}) { + const ticket = await prisma.ticket.create({ + data: { code, lotId: ctx.lot.id, isUsed: true, usedAt: new Date("2026-09-01T10:00:00Z"), userId: ctx.client.id }, + }); + const participation = await prisma.participation.create({ + data: { userId: ctx.client.id, ticketId: ticket.id, lotId: ctx.lot.id }, + }); + return prisma.claim.create({ + data: { + participationId: participation.id, + status: remisPar ? "DELIVERED" : "PENDING", + validatedByEmployeeId: remisPar?.id ?? null, + claimedAt: remisLe ?? null, + }, + }); +} + +beforeAll(async () => { + await cleanup(); + const h = await bcrypt.hash("Password123", 10); + const mk = (email, role, firstname, lastname, extra = {}) => + prisma.user.create({ data: { firstname, lastname, email: PREFIX + email, password: h, role, isEmailVerified: true, ...extra } }); + + ctx.admin = await mk("admin@test.local", "ADMIN", "Alice", "Admin"); + ctx.marc = await mk("marc@test.local", "EMPLOYEE", "Marc", "Durand"); + ctx.sophie = await mk("sophie@test.local", "EMPLOYEE", "Sophie", "Bernard", { mustChangePassword: true }); + ctx.client = await mk("client@test.local", "CLIENT", "Chloé", "Martin"); + ctx.tAdmin = tk(ctx.admin); + ctx.lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } }); + + await jouer(CODES[0], { remisPar: ctx.marc, remisLe: new Date("2026-09-10T14:30:00Z") }); + await jouer(CODES[1], { remisPar: ctx.marc, remisLe: new Date("2026-09-12T09:15:00Z") }); + await jouer(CODES[2]); + await prisma.ticket.create({ data: { code: CODES[3], lotId: ctx.lot.id } }); +}); + +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Liste des employes", () => { + test("renvoie les employes avec leur nombre de lots remis", async () => { + const res = await asAdmin(request(app).get("/api/admin/employees")).expect(200); + + const marc = res.body.employees.find((e) => e.id === ctx.marc.id); + const sophie = res.body.employees.find((e) => e.id === ctx.sophie.id); + expect(marc.deliveredCount).toBe(2); + expect(sophie.deliveredCount).toBe(0); + expect(sophie.mustChangePassword).toBe(true); + // Ni client, ni admin, ni hash de mot de passe. + expect(res.body.employees.every((e) => e.id !== ctx.client.id && e.id !== ctx.admin.id)).toBe(true); + expect(JSON.stringify(res.body)).not.toMatch(/"password"/); + }); + + test("filtre par nom ou email avec q", async () => { + const res = await asAdmin(request(app).get("/api/admin/employees?q=BERNARD")).expect(200); + expect(res.body.employees.map((e) => e.id)).toEqual([ctx.sophie.id]); + }); + + test("est reserve a l admin", async () => { + await request(app).get("/api/admin/employees").set("Authorization", `Bearer ${tk(ctx.marc)}`).expect(403); + }); +}); + +describe("Lots remis par un employe", () => { + test("liste les remises de l employe, de la plus recente a la plus ancienne", async () => { + const res = await asAdmin(request(app).get(`/api/admin/employees/${ctx.marc.id}/deliveries`)).expect(200); + + expect(res.body.employee.email).toBe(PREFIX + "marc@test.local"); + expect(res.body.deliveries.map((d) => d.ticketCode)).toEqual([CODES[1], CODES[0]]); + expect(res.body.deliveries[0].client.firstname).toBe("Chloé"); + expect(res.body.deliveries[0].lot.name).toBe(ctx.lot.name); + expect(new Date(res.body.deliveries[0].deliveredAt).toISOString()).toBe("2026-09-12T09:15:00.000Z"); + }); + + test("renvoie une liste vide pour un employe sans remise", async () => { + const res = await asAdmin(request(app).get(`/api/admin/employees/${ctx.sophie.id}/deliveries`)).expect(200); + expect(res.body.deliveries).toEqual([]); + }); + + test("refuse un identifiant qui n est pas un employe", async () => { + await asAdmin(request(app).get(`/api/admin/employees/${ctx.client.id}/deliveries`)).expect(404); + await asAdmin(request(app).get("/api/admin/employees/abc/deliveries")).expect(400); + }); +}); + +describe("Recherche par numero de ticket", () => { + test("indique l employe qui a remis le lot et la date, sans tenir compte de la casse", async () => { + const res = await asAdmin(request(app).get(`/api/admin/deliveries/${CODES[0].toLowerCase()}`)).expect(200); + + expect(res.body.ticketCode).toBe(CODES[0]); + expect(res.body.status).toBe("DELIVERED"); + expect(res.body.deliveredBy).toMatchObject({ id: ctx.marc.id, firstname: "Marc", lastname: "Durand" }); + expect(new Date(res.body.deliveredAt).toISOString()).toBe("2026-09-10T14:30:00.000Z"); + expect(res.body.client.email).toBe(PREFIX + "client@test.local"); + }); + + test("un lot gagne mais pas encore remis n a pas d employe", async () => { + const res = await asAdmin(request(app).get(`/api/admin/deliveries/${CODES[2]}`)).expect(200); + + // PENDING, ou CANCELLED si la période de retrait du jeu en base est passée. + expect(["PENDING", "CANCELLED"]).toContain(res.body.status); + expect(res.body.deliveredBy).toBeNull(); + expect(res.body.deliveredAt).toBeNull(); + }); + + test("un ticket jamais joue est signale comme tel", async () => { + const res = await asAdmin(request(app).get(`/api/admin/deliveries/${CODES[3]}`)).expect(200); + + expect(res.body.status).toBe("NOT_PLAYED"); + expect(res.body.client).toBeNull(); + }); + + test("un numero inconnu renvoie 404", async () => { + await asAdmin(request(app).get("/api/admin/deliveries/INEXISTANT")).expect(404); + }); +}); diff --git a/backend/tests/e2e.flow.test.js b/backend/tests/e2e.flow.test.js index ddb2ae2..fe92d46 100644 --- a/backend/tests/e2e.flow.test.js +++ b/backend/tests/e2e.flow.test.js @@ -21,13 +21,31 @@ const testEmail = "e2e-flow-test@test.com"; const ticketCode = "ZZE2ETEST1"; let token; let createdTicketId; +let settingsBackup; beforeAll(async () => { await prisma.user.deleteMany({ where: { email: testEmail } }); await prisma.ticket.deleteMany({ where: { code: ticketCode } }); + + // Le parcours a besoin d'un jeu ouvert, quelle que soit la date de début + // enregistrée en base (par défaut le 2 octobre 2026) : on la place à hier + // le temps du test, puis on la restaure. + settingsBackup = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + const hier = new Date(Date.now() - 24 * 60 * 60 * 1000); + if (settingsBackup) { + await prisma.gameSettings.update({ where: { id: settingsBackup.id }, data: { startDate: hier } }); + } else { + await prisma.gameSettings.create({ data: { startDate: hier } }); + } }); afterAll(async () => { + if (settingsBackup) { + await prisma.gameSettings.update({ where: { id: settingsBackup.id }, data: { startDate: settingsBackup.startDate } }); + } else { + await prisma.gameSettings.deleteMany({}); + } + const user = await prisma.user.findUnique({ where: { email: testEmail } }); if (user) { await prisma.claim.deleteMany({ where: { participation: { userId: user.id } } }); @@ -48,7 +66,8 @@ describe("THE TIP TOP - FULL E2E FLOW", () => { firstname: "E2E", lastname: "Test", email: testEmail, - password: "Password123", + password: "Password123!", + confirmPassword: "Password123!", }); expect(res.statusCode).toBe(201); @@ -62,7 +81,7 @@ describe("THE TIP TOP - FULL E2E FLOW", () => { const res = await request(app).post("/api/auth/login").send({ email: testEmail, - password: "Password123", + password: "Password123!", }); expect(res.statusCode).toBe(200); diff --git a/backend/tests/employeeOnboarding.test.js b/backend/tests/employeeOnboarding.test.js new file mode 100644 index 0000000..1f052b7 --- /dev/null +++ b/backend/tests/employeeOnboarding.test.js @@ -0,0 +1,166 @@ +const request = require("supertest"); +const bcrypt = require("bcrypt"); +const jwt = require("jsonwebtoken"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const mailer = require("../src/config/mailer"); +const { passwordRuleError } = require("../src/validations/password.validation"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() })); + +const PREFIX = "onboarding-"; +const ADMIN_EMAIL = PREFIX + "admin@test.local"; +const EMPLOYEE_EMAIL = PREFIX + "employe@test.local"; +const ctx = {}; + +const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" }); + +// Récupère le mot de passe provisoire dans l'email envoyé à l'employé. +function tempPasswordFromMail() { + const html = mailer.sendMail.mock.calls[0][0].html; + return html.match(/Mot de passe provisoire : ]*>([^<]+)<\/strong>/)[1]; +} + +async function cleanup() { + await prisma.user.deleteMany({ where: { email: { startsWith: PREFIX } } }); +} + +beforeAll(async () => { + await cleanup(); + const h = await bcrypt.hash("Password123", 10); + ctx.admin = await prisma.user.create({ + data: { firstname: "Alice", lastname: "Admin", email: ADMIN_EMAIL, password: h, role: "ADMIN", isEmailVerified: true }, + }); + ctx.tAdmin = tk(ctx.admin); +}); + +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Creation d un employe avec mot de passe provisoire", () => { + test("un echec d envoi de l email annule la creation du compte", async () => { + mailer.sendMail.mockRejectedValueOnce(new Error("SMTP indisponible")); + jest.spyOn(console, "error").mockImplementation(() => {}); + + const res = await request(app) + .post("/api/admin/employees") + .set("Authorization", `Bearer ${ctx.tAdmin}`) + .send({ firstname: "Marc", lastname: "Durand", email: EMPLOYEE_EMAIL }); + + console.error.mockRestore(); + expect(res.status).toBe(502); + expect(await prisma.user.findUnique({ where: { email: EMPLOYEE_EMAIL } })).toBeNull(); + }); + + test("l admin cree l employe sans mot de passe, un mot de passe provisoire est envoye", async () => { + mailer.sendMail.mockClear(); + mailer.sendMail.mockResolvedValue(true); + + const res = await request(app) + .post("/api/admin/employees") + .set("Authorization", `Bearer ${ctx.tAdmin}`) + // Un mot de passe choisi par l'admin est ignoré. + .send({ firstname: "Marc", lastname: "Durand", email: EMPLOYEE_EMAIL, password: "ChoixAdmin123" }); + + expect(res.status).toBe(201); + expect(res.body.employee.role).toBe("EMPLOYEE"); + // Le mot de passe ne transite jamais par la réponse faite à l'admin. + expect(JSON.stringify(res.body)).not.toMatch(/password/i); + + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + expect(mailer.sendMail.mock.calls[0][0].to).toBe(EMPLOYEE_EMAIL); + ctx.tempPassword = tempPasswordFromMail(); + expect(ctx.tempPassword).toHaveLength(12); + expect(ctx.tempPassword).not.toBe("ChoixAdmin123"); + // Le provisoire respecte lui aussi la règle commune. + expect(passwordRuleError(ctx.tempPassword)).toBeNull(); + + const enBase = await prisma.user.findUnique({ where: { email: EMPLOYEE_EMAIL } }); + expect(enBase.mustChangePassword).toBe(true); + expect(await bcrypt.compare("ChoixAdmin123", enBase.password)).toBe(false); + }); + + test("la connexion avec le mot de passe provisoire signale le changement obligatoire", async () => { + const res = await request(app).post("/api/auth/login").send({ email: EMPLOYEE_EMAIL, password: ctx.tempPassword }); + + expect(res.status).toBe(200); + expect(res.body.user.mustChangePassword).toBe(true); + ctx.tEmployee = res.body.token; + }); + + test("tant que le mot de passe n est pas change, l API est bloquee sauf /me", async () => { + const bloque = await request(app).get("/api/employee/ticket/INCONNU").set("Authorization", `Bearer ${ctx.tEmployee}`); + expect(bloque.status).toBe(403); + expect(bloque.body.code).toBe("PASSWORD_CHANGE_REQUIRED"); + + const changement = await request(app) + .patch("/api/auth/change-password") + .set("Authorization", `Bearer ${ctx.tEmployee}`) + .send({ currentPassword: ctx.tempPassword, newPassword: "Contournement1" }); + expect(changement.status).toBe(403); + + const me = await request(app).get("/api/auth/me").set("Authorization", `Bearer ${ctx.tEmployee}`); + expect(me.status).toBe(200); + expect(me.body.user.mustChangePassword).toBe(true); + }); + + test("le nouveau mot de passe doit etre confirme et different du provisoire", async () => { + const auth = { Authorization: `Bearer ${ctx.tEmployee}` }; + + const different = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: "NouveauMdp123!", confirmPassword: "AutreMdp456!" }); + expect(different.status).toBe(400); + expect(different.body.message).toMatch(/ne correspondent pas/); + + const court = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: "court", confirmPassword: "court" }); + expect(court.status).toBe(400); + + const sansSpecial = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: "NouveauMdp123", confirmPassword: "NouveauMdp123" }); + expect(sansSpecial.status).toBe(400); + expect(sansSpecial.body.message).toMatch(/caractère spécial/); + + const identique = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: ctx.tempPassword, confirmPassword: ctx.tempPassword }); + expect(identique.status).toBe(400); + expect(identique.body.message).toMatch(/provisoire/); + }); + + test("apres le changement, l acces est retabli et seul le nouveau mot de passe fonctionne", async () => { + const auth = { Authorization: `Bearer ${ctx.tEmployee}` }; + + const ok = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: "NouveauMdp123!", confirmPassword: "NouveauMdp123!" }); + expect(ok.status).toBe(200); + + // Le ticket n'existe pas : un 404 prouve que la requête a franchi le filtre. + const acces = await request(app).get("/api/employee/ticket/INCONNU").set(auth); + expect(acces.status).not.toBe(403); + + const encore = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: "EncoreUnAutre1!", confirmPassword: "EncoreUnAutre1!" }); + expect(encore.status).toBe(400); + + const ancien = await request(app).post("/api/auth/login").send({ email: EMPLOYEE_EMAIL, password: ctx.tempPassword }); + expect(ancien.status).toBe(401); + + const nouveau = await request(app).post("/api/auth/login").send({ email: EMPLOYEE_EMAIL, password: "NouveauMdp123!" }); + expect(nouveau.status).toBe(200); + expect(nouveau.body.user.mustChangePassword).toBe(false); + }); +}); diff --git a/backend/tests/forgotPassword.test.js b/backend/tests/forgotPassword.test.js new file mode 100644 index 0000000..0b95155 --- /dev/null +++ b/backend/tests/forgotPassword.test.js @@ -0,0 +1,167 @@ +const request = require("supertest"); +const bcrypt = require("bcrypt"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const mailer = require("../src/config/mailer"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) })); + +const EMAIL = "forgot-password-test@test.local"; +const NON_VERIFIE = "forgot-password-unverified@test.local"; +const INSCRIT = "forgot-password-register@test.local"; + +// Jeton extrait du lien reçu par email. +function tokenFromMail() { + const html = mailer.sendMail.mock.calls.at(-1)[0].html; + return html.match(/\/definir-mot-de-passe\/([a-f0-9]+)"/)[1]; +} + +async function cleanup() { + await prisma.user.deleteMany({ where: { email: { in: [EMAIL, NON_VERIFIE, INSCRIT] } } }); +} + +beforeAll(async () => { + await cleanup(); + const h = await bcrypt.hash("AncienMdp1!", 10); + await prisma.user.create({ data: { firstname: "Léa", lastname: "Durand", email: EMAIL, password: h, isEmailVerified: true } }); + await prisma.user.create({ data: { firstname: "Tom", lastname: "Petit", email: NON_VERIFIE, password: h, isEmailVerified: false } }); +}); + +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Mot de passe oublie", () => { + beforeEach(() => mailer.sendMail.mockClear()); + + test("un email inconnu recoit la meme reponse, sans envoi", async () => { + const res = await request(app).post("/api/auth/forgot-password").send({ email: "inconnu@test.local" }); + + expect(res.status).toBe(200); + expect(res.body.message).toMatch(/Si un compte existe/); + expect(mailer.sendMail).not.toHaveBeenCalled(); + }); + + test("un email connu recoit un lien vers la page du front", async () => { + const res = await request(app).post("/api/auth/forgot-password").send({ email: EMAIL }); + + expect(res.status).toBe(200); + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + const mail = mailer.sendMail.mock.calls[0][0]; + expect(mail.to).toBe(EMAIL); + const frontendUrl = (process.env.FRONTEND_URL || "http://localhost:5173").split(",")[0].trim(); + expect(mail.html).toContain(`${frontendUrl}/definir-mot-de-passe/`); + expect(tokenFromMail()).toHaveLength(64); + }); + + test("une nouvelle demande invalide le lien precedent", async () => { + await request(app).post("/api/auth/forgot-password").send({ email: EMAIL }); + const ancien = tokenFromMail(); + await request(app).post("/api/auth/forgot-password").send({ email: EMAIL }); + const nouveau = tokenFromMail(); + + const res = await request(app) + .post(`/api/auth/reset-password/${ancien}`) + .send({ password: "NouveauMdp1!", confirmPassword: "NouveauMdp1!" }); + expect(res.status).toBe(400); + expect(nouveau).not.toBe(ancien); + }); + + test("le lien permet de changer le mot de passe une seule fois", async () => { + await request(app).post("/api/auth/forgot-password").send({ email: EMAIL }); + const token = tokenFromMail(); + + const ok = await request(app) + .post(`/api/auth/reset-password/${token}`) + .send({ password: "NouveauMdp1!", confirmPassword: "NouveauMdp1!" }); + expect(ok.status).toBe(200); + + expect((await request(app).post("/api/auth/login").send({ email: EMAIL, password: "AncienMdp1!" })).status).toBe(401); + expect((await request(app).post("/api/auth/login").send({ email: EMAIL, password: "NouveauMdp1!" })).status).toBe(200); + + const reutilise = await request(app) + .post(`/api/auth/reset-password/${token}`) + .send({ password: "EncoreAutre1!", confirmPassword: "EncoreAutre1!" }); + expect(reutilise.status).toBe(400); + expect(reutilise.body.message).toMatch(/invalide ou expiré/); + }); + + test("un lien expire est refuse", async () => { + await request(app).post("/api/auth/forgot-password").send({ email: EMAIL }); + const token = tokenFromMail(); + await prisma.user.update({ where: { email: EMAIL }, data: { resetPasswordExpires: new Date(Date.now() - 1000) } }); + + const res = await request(app) + .post(`/api/auth/reset-password/${token}`) + .send({ password: "EncoreAutre1!", confirmPassword: "EncoreAutre1!" }); + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/invalide ou expiré/); + }); + + test("l email est reconnu quelle que soit la casse et malgre des espaces", async () => { + const res = await request(app).post("/api/auth/forgot-password").send({ email: ` ${EMAIL.toUpperCase()} ` }); + + expect(res.status).toBe(200); + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + expect(mailer.sendMail.mock.calls[0][0].to).toBe(EMAIL); + }); + + test("la reinitialisation valide un compte jamais verifie, qui peut ensuite se connecter", async () => { + await request(app).post("/api/auth/forgot-password").send({ email: NON_VERIFIE }); + await request(app) + .post(`/api/auth/reset-password/${tokenFromMail()}`) + .send({ password: "NouveauMdp1!", confirmPassword: "NouveauMdp1!" }) + .expect(200); + + const login = await request(app).post("/api/auth/login").send({ email: NON_VERIFIE, password: "NouveauMdp1!" }); + expect(login.status).toBe(200); + const enBase = await prisma.user.findUnique({ where: { email: NON_VERIFIE } }); + expect(enBase.isEmailVerified).toBe(true); + expect(enBase.emailVerifyToken).toBeNull(); + }); + + test("le profil connecte recoit le lien sur l adresse du compte, sans captcha", async () => { + await request(app).post("/api/auth/password-setup-link").expect(401); + + const login = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "NouveauMdp1!" }).expect(200); + const res = await request(app) + .post("/api/auth/password-setup-link") + .set("Authorization", `Bearer ${login.body.token}`); + + expect(res.status).toBe(200); + expect(mailer.sendMail.mock.calls[0][0].to).toBe(EMAIL); + expect(tokenFromMail()).toHaveLength(64); + }); +}); + +describe("Emails normalises a l inscription et a la connexion", () => { + test("un email saisi avec majuscules est stocke en minuscules et reconnu a la connexion", async () => { + const res = await request(app).post("/api/auth/register").send({ + firstname: "Nora", + lastname: "Blanc", + email: ` ${INSCRIT.replace("forgot", "FORGOT")} `, + password: "MotDePasse1!", + confirmPassword: "MotDePasse1!", + birthdate: "1990-05-14", + }); + expect(res.status).toBe(201); + + const enBase = await prisma.user.findUnique({ where: { email: INSCRIT } }); + expect(enBase).not.toBeNull(); + + // Même adresse avec une autre casse : doublon refusé. + const doublon = await request(app).post("/api/auth/register").send({ + firstname: "Nora", + lastname: "Blanc", + email: INSCRIT.toUpperCase(), + password: "MotDePasse1!", + confirmPassword: "MotDePasse1!", + }); + expect(doublon.status).toBe(409); + + await prisma.user.update({ where: { email: INSCRIT }, data: { isEmailVerified: true } }); + const login = await request(app).post("/api/auth/login").send({ email: INSCRIT.toUpperCase(), password: "MotDePasse1!" }); + expect(login.status).toBe(200); + }); +}); diff --git a/backend/tests/gamePhases.test.js b/backend/tests/gamePhases.test.js index b893306..432f081 100644 --- a/backend/tests/gamePhases.test.js +++ b/backend/tests/gamePhases.test.js @@ -87,6 +87,11 @@ describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => { log("phase :", vue.body.gameWindow.phase, "| participations ouvertes :", vue.body.gameWindow.isParticipationOpen); expect(vue.body.gameWindow.phase).toBe("NOT_CONFIGURED"); + // Route publique de la page d'accueil : sans configuration, pas de date, + // le front affiche alors les dates par défaut. + const publique = await request(app).get("/api/game/window").expect(200); + expect(publique.body).toMatchObject({ configured: false, startDate: null }); + const essai = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] }); log("un client tente de jouer ->", essai.status, essai.body.message); expect(essai.status).toBe(410); @@ -108,6 +113,12 @@ describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => { log("PUT game-settings ->", r.status, "| phase :", r.body.gameWindow.phase); expect(r.body.gameWindow.phase).toBe("SCHEDULED"); + // La page d'accueil reçoit aussitôt la nouvelle date, sans authentification. + const publique = await request(app).get("/api/game/window").expect(200); + expect(publique.body.configured).toBe(true); + expect(publique.body.startDate).toBe(futur); + expect(new Date(publique.body.participationDeadline).getTime()).toBe(new Date(futur).getTime() + 30 * DAY); + const anticipe = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] }); log("client joue avant ouverture ->", anticipe.status); log("message :", JSON.stringify(anticipe.body.message)); diff --git a/backend/tests/googlePassword.test.js b/backend/tests/googlePassword.test.js index b0ceb71..5992c5d 100644 --- a/backend/tests/googlePassword.test.js +++ b/backend/tests/googlePassword.test.js @@ -87,7 +87,7 @@ decrire("Inscription Google et definition du mot de passe", () => { test("une confirmation qui ne correspond pas est refusee", async () => { const res = await request(app) .post("/api/auth/reset-password/" + ctxToken) - .send({ password: "MonMotDePasse1", confirmPassword: "AutreMotDePasse2" }); + .send({ password: "MonMotDePasse1!", confirmPassword: "AutreMotDePasse2!" }); expect(res.status).toBe(400); expect(res.body.message).toMatch(/ne correspondent pas/); @@ -105,14 +105,23 @@ decrire("Inscription Google et definition du mot de passe", () => { expect(res.body.message).toMatch(/8 caractères/); }); + test("un mot de passe sans caractere special est refuse", async () => { + const res = await request(app) + .post("/api/auth/reset-password/" + ctxToken) + .send({ password: "MonMotDePasse1", confirmPassword: "MonMotDePasse1" }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/caractère spécial/); + }); + test("le mot de passe defini permet ensuite de se connecter et de le changer", async () => { const ok = await request(app) .post("/api/auth/reset-password/" + ctxToken) - .send({ password: "MonMotDePasse1", confirmPassword: "MonMotDePasse1" }) + .send({ password: "MonMotDePasse1!", confirmPassword: "MonMotDePasse1!" }) .expect(200); expect(ok.body.message).toMatch(/réinitialisé/); - const login = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "MonMotDePasse1" }).expect(200); + const login = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "MonMotDePasse1!" }).expect(200); expect(login.body.token).toBeDefined(); // Le jeton a bien ete consomme. @@ -125,8 +134,16 @@ decrire("Inscription Google et definition du mot de passe", () => { const change = await request(app) .patch("/api/auth/change-password") .set("Authorization", "Bearer " + login.body.token) - .send({ currentPassword: "MonMotDePasse1", newPassword: "EncoreUnAutre2" }); + .send({ currentPassword: "MonMotDePasse1!", newPassword: "EncoreUnAutre2!" }); expect(change.status).toBe(200); + + // Même règle qu'à l'inscription pour le changement depuis le profil. + const sansSpecial = await request(app) + .patch("/api/auth/change-password") + .set("Authorization", "Bearer " + login.body.token) + .send({ currentPassword: "EncoreUnAutre2!", newPassword: "SansCaractere3" }); + expect(sansSpecial.status).toBe(400); + expect(sansSpecial.body.message).toMatch(/caractère spécial/); }); test("une seconde connexion Google ne renvoie pas de nouveau lien", async () => { diff --git a/backend/tests/register.mail.test.js b/backend/tests/register.mail.test.js index 463821c..6d2cb6c 100644 --- a/backend/tests/register.mail.test.js +++ b/backend/tests/register.mail.test.js @@ -15,7 +15,8 @@ const payload = { firstname: "Camille", lastname: "Martin", email: testEmail, - password: "Password123", + password: "Password123!", + confirmPassword: "Password123!", birthdate: "1990-05-14", gender: "Femme", }; diff --git a/backend/tests/registerPassword.test.js b/backend/tests/registerPassword.test.js new file mode 100644 index 0000000..f288491 --- /dev/null +++ b/backend/tests/registerPassword.test.js @@ -0,0 +1,62 @@ +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) })); + +const EMAIL = "register-password-test@test.local"; + +const payload = (password, confirmPassword = password) => ({ + firstname: "Camille", + lastname: "Martin", + email: EMAIL, + password, + confirmPassword, + birthdate: "1990-05-14", +}); + +async function cleanup() { + await prisma.user.deleteMany({ where: { email: EMAIL } }); +} + +beforeEach(cleanup); +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Regles du mot de passe a l inscription", () => { + test.each([ + ["trop court", "Abc12!", /8 caractères/], + ["sans caractere special", "MotDePasse123", /caractère spécial/], + ["accents et espaces ne comptent pas comme speciaux", "Mot de passé 123", /caractère spécial/], + ])("refuse un mot de passe %s", async (_, password, message) => { + const res = await request(app).post("/api/auth/register").send(payload(password)); + + expect(res.status).toBe(400); + expect(res.body.errors.password[0]).toMatch(message); + expect(await prisma.user.findUnique({ where: { email: EMAIL } })).toBeNull(); + }); + + test("refuse une confirmation differente", async () => { + const res = await request(app).post("/api/auth/register").send(payload("MotDePasse1!", "MotDePasse2!")); + + expect(res.status).toBe(400); + expect(res.body.errors.confirmPassword[0]).toMatch(/ne correspondent pas/); + }); + + test("refuse une inscription sans confirmation", async () => { + const { confirmPassword, ...sansConfirmation } = payload("MotDePasse1!"); + const res = await request(app).post("/api/auth/register").send(sansConfirmation); + + expect(res.status).toBe(400); + expect(res.body.errors.confirmPassword).toBeDefined(); + }); + + test("accepte 8 caracteres avec un caractere special", async () => { + const res = await request(app).post("/api/auth/register").send(payload("abcdefg#")); + + expect(res.status).toBe(201); + expect(await prisma.user.findUnique({ where: { email: EMAIL } })).not.toBeNull(); + }); +}); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2601f0c..a4a39a6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,6 +7,8 @@ import Connexion from './pages/Connexion' import Inscription from './pages/Inscription' import AuthCallback from './pages/AuthCallback' import DefinirMotDePasse from './pages/DefinirMotDePasse' +import MotDePasseOublie from './pages/MotDePasseOublie' +import NouveauMotDePasse from './pages/NouveauMotDePasse' import Accueil from './pages/Accueil' import NotreConcept from './pages/NotreConcept' import ReglesJeu from './pages/ReglesJeu' @@ -33,7 +35,9 @@ export default function App() { } /> } /> } /> + } /> } /> + } /> {/* Pages publiques */} } /> diff --git a/frontend/src/components/PasswordRules.jsx b/frontend/src/components/PasswordRules.jsx new file mode 100644 index 0000000..279deeb --- /dev/null +++ b/frontend/src/components/PasswordRules.jsx @@ -0,0 +1,21 @@ +import { PASSWORD_RULES } from '../utils/passwordRules' + +// Règles du mot de passe, cochées au fil de la saisie. À relier au champ via +// aria-describedby={id}. +export default function PasswordRules({ id, password }) { + return ( +
    + {PASSWORD_RULES.map((rule) => { + const ok = rule.test(password) + return ( +
  • + + {rule.label} + {ok ? ' : respecté' : ' : non respecté'} +
  • + ) + })} +
+ ) +} diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx index f78f05c..77b9fc4 100644 --- a/frontend/src/components/ProtectedRoute.jsx +++ b/frontend/src/components/ProtectedRoute.jsx @@ -1,5 +1,6 @@ import { Navigate } from 'react-router-dom' import { useAuth } from '../context/AuthContext' +import { homeFor, INITIAL_PASSWORD_PATH } from '../config/roles' export default function ProtectedRoute({ children, roles }) { const { user } = useAuth() @@ -8,9 +9,14 @@ export default function ProtectedRoute({ children, roles }) { return } + // Mot de passe provisoire : aucune page protégée tant qu'il n'est pas + // remplacé (le backend refuse de toute façon les appels à l'API). + if (user.mustChangePassword) { + return + } + if (roles && !roles.includes(user.role)) { - const redirects = { ADMIN: '/admin', EMPLOYEE: '/employe', CLIENT: '/mes-gains' } - return + return } return children diff --git a/frontend/src/components/admin/EmployeeDeliveries.jsx b/frontend/src/components/admin/EmployeeDeliveries.jsx new file mode 100644 index 0000000..3b8c851 --- /dev/null +++ b/frontend/src/components/admin/EmployeeDeliveries.jsx @@ -0,0 +1,288 @@ +import { useEffect, useRef, useState } from 'react' +import { getEmployeesApi, getEmployeeDeliveriesApi, getDeliveryByTicketCodeApi } from '../../services/admin' + +const STATUS_BADGES = { + DELIVERED: { label: 'Lot remis', className: 'bg-green-100 text-green-700' }, + PENDING: { label: 'En attente de retrait', className: 'bg-amber-100 text-amber-700' }, + CANCELLED: { label: 'Expiré', className: 'bg-gray-200 text-gray-700' }, + NOT_PLAYED: { label: 'Pas encore joué', className: 'bg-gray-100 text-gray-600' }, +} + +const INPUT_CLASS = + 'w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]' +const BUTTON_CLASS = + 'bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm' + +function formatDateTime(dateStr) { + return new Date(dateStr).toLocaleString('fr-FR', { + day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', + }) +} + +const fullName = (person) => `${person.firstname} ${person.lastname}` + +// Traçabilité des remises de lots : liste des employés, lots remis par chacun, +// et recherche inverse par numéro de ticket. +// `refreshKey` change quand un employé est créé, pour recharger la liste. +export default function EmployeeDeliveries({ refreshKey, onAuthError }) { + const [employees, setEmployees] = useState(null) + const [employeesError, setEmployeesError] = useState('') + const [filter, setFilter] = useState('') + + const [selected, setSelected] = useState(null) + const [deliveries, setDeliveries] = useState(null) + const [deliveriesLoading, setDeliveriesLoading] = useState(false) + const [deliveriesError, setDeliveriesError] = useState('') + const detailRef = useRef(null) + + const [ticketCode, setTicketCode] = useState('') + const [ticketResult, setTicketResult] = useState(null) + const [ticketLoading, setTicketLoading] = useState(false) + const [ticketError, setTicketError] = useState('') + + useEffect(() => { + getEmployeesApi() + .then((data) => { + setEmployees(data.employees) + setEmployeesError('') + }) + .catch((err) => { + if (!onAuthError(err)) setEmployeesError(err.message) + }) + }, [refreshKey, onAuthError]) + + const openEmployee = async (employee) => { + setSelected(employee) + setDeliveries(null) + setDeliveriesError('') + setDeliveriesLoading(true) + try { + const data = await getEmployeeDeliveriesApi(employee.id) + setDeliveries(data.deliveries) + } catch (err) { + if (!onAuthError(err)) setDeliveriesError(err.message) + } finally { + setDeliveriesLoading(false) + detailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + } + + const handleTicketSearch = async (e) => { + e.preventDefault() + setTicketError('') + setTicketResult(null) + setTicketLoading(true) + try { + setTicketResult(await getDeliveryByTicketCodeApi(ticketCode)) + } catch (err) { + if (!onAuthError(err)) setTicketError(err.message) + } finally { + setTicketLoading(false) + } + } + + const needle = filter.trim().toLowerCase() + const visibleEmployees = (employees ?? []).filter((employee) => + !needle || `${fullName(employee)} ${employee.email}`.toLowerCase().includes(needle) + ) + + return ( +
+
+

Employés et remises des lots

+

+ Retrouvez quel employé a remis un lot et à quelle date, par numéro de ticket ou par employé. +

+
+ + {/* ── Recherche par numéro de ticket ── */} +
+
+
+ + setTicketCode(e.target.value)} + required + placeholder="Ex. A1B2C3D4E5" + className={`${INPUT_CLASS} uppercase placeholder:normal-case`} + /> +
+ +
+ + {ticketError &&

{ticketError}

} + + {ticketResult && ( +
+
+

{ticketResult.ticketCode}

+ + {STATUS_BADGES[ticketResult.status]?.label ?? ticketResult.status} + +
+

Lot : {ticketResult.lot.name}

+ {ticketResult.client && ( +

+ Gagné par : {fullName(ticketResult.client)}{' '} + ({ticketResult.client.email}) + {ticketResult.playedAt && <> le {formatDateTime(ticketResult.playedAt)}} +

+ )} + {ticketResult.deliveredBy ? ( +

+ Remis par :{' '} + {' '} + le {formatDateTime(ticketResult.deliveredAt)} +

+ ) : ( + ticketResult.status !== 'NOT_PLAYED' && ( +

Ce lot n'a pas encore été remis.

+ ) + )} +
+ )} +
+ + {/* ── Liste des employés ── */} +
+
+

+ Comptes employés + {employees && - {employees.length}} +

+
+ + setFilter(e.target.value)} + placeholder="Rechercher un employé…" + className={INPUT_CLASS} + /> +
+
+ + {employeesError &&

{employeesError}

} + {!employees && !employeesError &&

Chargement…

} + + {employees && ( +
+ + + + + + + + + + + {visibleEmployees.length === 0 && ( + + + + )} + {visibleEmployees.map((employee) => ( + + + + + + + ))} + +
NomEmailCréé leLots remis
+ {employees.length === 0 ? "Aucun compte employé pour l'instant" : 'Aucun employé ne correspond à la recherche'} +
+ + {employee.mustChangePassword && ( + Première connexion en attente + )} + {employee.email} + {new Date(employee.createdAt).toLocaleDateString('fr-FR')} + {employee.deliveredCount}
+
+ )} +
+ + {/* ── Lots remis par l'employé sélectionné ── */} + {selected && ( +
+
+

+ Lots remis par {fullName(selected)} + {deliveries && — {deliveries.length}} +

+ +
+ + {deliveriesLoading &&

Chargement…

} + {deliveriesError &&

{deliveriesError}

} + + {deliveries && ( +
+ + + + + + + + + + + {deliveries.length === 0 && ( + + + + )} + {deliveries.map((delivery) => ( + + + + + + + ))} + +
TicketLotClientRemis le
+ Cet employé n'a encore remis aucun lot +
{delivery.ticketCode}{delivery.lot.name} + {fullName(delivery.client)} + {delivery.client.email} + + {formatDateTime(delivery.deliveredAt)} +
+
+ )} +
+ )} +
+ ) +} diff --git a/frontend/src/config/roles.js b/frontend/src/config/roles.js new file mode 100644 index 0000000..5073387 --- /dev/null +++ b/frontend/src/config/roles.js @@ -0,0 +1,10 @@ +// Page d'accueil de chaque rôle après connexion. +export const HOME_BY_ROLE = { ADMIN: '/admin', EMPLOYEE: '/employe', CLIENT: '/mes-gains' } + +// Page imposée tant qu'un mot de passe provisoire n'a pas été remplacé. +export const INITIAL_PASSWORD_PATH = '/nouveau-mot-de-passe' + +export function homeFor(user) { + if (user?.mustChangePassword) return INITIAL_PASSWORD_PATH + return HOME_BY_ROLE[user?.role] || '/connexion' +} diff --git a/frontend/src/hooks/useGameDates.js b/frontend/src/hooks/useGameDates.js new file mode 100644 index 0000000..76e3ac1 --- /dev/null +++ b/frontend/src/hooks/useGameDates.js @@ -0,0 +1,26 @@ +import { useEffect, useMemo, useState } from 'react' +import { getGameWindowApi } from '../services/game' +import { toGameDates } from '../utils/gameDates' + +// Dates du jeu-concours pour l'affichage public. Rend immédiatement les dates +// par défaut (2 → 31 octobre 2026), puis celles fixées par l'admin dès que +// l'API répond. Rechargées à chaque montage : une date modifiée dans le +// tableau de bord apparaît dès le retour sur la page. +export default function useGameDates() { + const [gameWindow, setGameWindow] = useState(null) + + useEffect(() => { + let active = true + getGameWindowApi() + .then((data) => { + if (active) setGameWindow(data) + }) + // API injoignable : les dates par défaut restent affichées. + .catch(() => {}) + return () => { + active = false + } + }, []) + + return useMemo(() => toGameDates(gameWindow), [gameWindow]) +} diff --git a/frontend/src/pages/Accueil.jsx b/frontend/src/pages/Accueil.jsx index c60e30e..bde32e0 100644 --- a/frontend/src/pages/Accueil.jsx +++ b/frontend/src/pages/Accueil.jsx @@ -1,6 +1,8 @@ import { useMemo } from 'react' import { Link } from 'react-router-dom' import usePageMeta from '../hooks/usePageMeta' +import useGameDates from '../hooks/useGameDates' +import { formatDay, formatRange } from '../utils/gameDates' import bannerImg from '../assets/img/banner-accueil.webp' const gains = [ @@ -38,10 +40,11 @@ function createTeaParticles(count) { })) } -const faqs = [ +// Les dates viennent du tableau de bord admin (voir useGameDates). +const buildFaqs = ({ start, lastPlayDay, lastClaimDay }) => [ { question: 'Comment obtenir un code ?', - answer: "Achetez pour plus de 49 € dans notre boutique Thé Tip Top de Nice, entre le 2 et le 31 octobre 2026 : un code unique à 10 caractères figure sur votre ticket de caisse ou votre facture.", + answer: `Achetez pour plus de 49 € dans notre boutique Thé Tip Top de Nice, du ${formatRange(start, lastPlayDay)} : un code unique à 10 caractères figure sur votre ticket de caisse ou votre facture.`, }, { question: 'Quels sont les lots à gagner ?', @@ -62,7 +65,7 @@ const faqs = [ }, { question: 'Jusqu\'à quand puis-je jouer ?', - answer: 'La saisie des codes est ouverte du 2 au 31 octobre 2026 inclus. Les lots sont ensuite à retirer jusqu\'au 30 novembre 2026.', + answer: `La saisie des codes est ouverte du ${formatRange(start, lastPlayDay)} inclus. Les lots sont ensuite à retirer jusqu'au ${formatDay(lastClaimDay)}.`, }, { question: 'Que faire si mon code n\'est pas reconnu ?', @@ -70,11 +73,11 @@ const faqs = [ }, { question: 'Où retirer mon lot ?', - answer: 'Dans notre boutique de Nice, sur présentation de votre compte utilisateur validé et de la confirmation de gain affichée dans votre espace « Mes gains », jusqu\'au 30 novembre 2026.', + answer: `Dans notre boutique de Nice, sur présentation de votre compte utilisateur validé et de la confirmation de gain affichée dans votre espace « Mes gains », jusqu'au ${formatDay(lastClaimDay)}.`, }, ] -const faqJsonLd = { +const buildFaqJsonLd = (faqs) => ({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faqs.map((faq) => ({ @@ -85,12 +88,18 @@ const faqJsonLd = { text: faq.plainAnswer || faq.answer, }, })), -} +}) export default function Accueil() { + const gameDates = useGameDates() + const period = formatRange(gameDates.start, gameDates.lastPlayDay) + const faqs = useMemo(() => buildFaqs(gameDates), [gameDates]) + const faqJsonLd = useMemo(() => buildFaqJsonLd(faqs), [faqs]) + const bannerAlt = `Jeu-concours 100 % Gagnant Thé Tip Top, du ${period} : tentez de gagner un an de thé d'une valeur de 360 € pour l'ouverture de notre 10e boutique à Nice.` + usePageMeta({ - title: 'Jeu-concours 100 % Gagnant du 2 au 31 octobre | Thé Tip Top', - description: "Du 2 au 31 octobre, tout ticket de plus de 49 € de notre boutique de Nice porte un code 100 % gagnant. Saisissez le vôtre et repartez avec votre lot.", + title: `Jeu-concours 100 % Gagnant du ${formatRange(gameDates.start, gameDates.lastPlayDay, { withYear: false })} | Thé Tip Top`, + description: `Du ${formatRange(gameDates.start, gameDates.lastPlayDay, { withYear: false })}, tout ticket de plus de 49 € de notre boutique de Nice porte un code 100 % gagnant. Saisissez le vôtre et repartez avec votre lot.`, }) const teaParticles = useMemo(() => createTeaParticles(30), []) @@ -130,7 +139,7 @@ export default function Accueil() {

- Du 02 au 31 octobre 2026 + Du {formatRange(gameDates.start, gameDates.lastPlayDay, { padDay: true })}

Jeu-concours{' '} @@ -142,13 +151,13 @@ export default function Accueil() {
Jeu-concours 100 % Gagnant Thé Tip Top, du 2 au 31 octobre 2026 : tentez de gagner un an de thé d'une valeur de 360 € pour l'ouverture de notre 10e boutique à Nice.
Jeu-concours 100 % Gagnant Thé Tip Top, du 2 au 31 octobre 2026 : tentez de gagner un an de thé d'une valeur de 360 € pour l'ouverture de notre 10e boutique à Nice. : heure locale, sans fuseau. +function toDateTimeLocalValue(dateStr) { + const date = new Date(dateStr) + const pad = (n) => String(n).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` +} + function formatDate(dateStr) { return new Date(dateStr).toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric', @@ -68,15 +77,19 @@ export default function Admin() { const [emailingError, setEmailingError] = useState('') // Création d'un compte employé - const [employeeForm, setEmployeeForm] = useState({ firstname: '', lastname: '', email: '', password: '' }) + const [employeeForm, setEmployeeForm] = useState({ firstname: '', lastname: '', email: '' }) const [employeeSaving, setEmployeeSaving] = useState(false) const [employeeError, setEmployeeError] = useState('') const [employeeOk, setEmployeeOk] = useState('') + // Incrémenté à chaque création pour recharger la liste des employés. + const [employeesVersion, setEmployeesVersion] = useState(0) useEffect(() => { Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()]) .then(([s, u, w]) => { setStats(s) + // Le champ part de la date enregistrée, celle affichée sur l'accueil. + if (s.gameWindow?.startDate) setStartDateInput(toDateTimeLocalValue(s.gameWindow.startDate)) setUsers(u.users) setWinner(w) }) @@ -96,8 +109,8 @@ export default function Admin() { // convertit en ISO pour que le serveur reçoive un instant sans ambiguïté. const data = await setGameSettingsApi(new Date(startDateInput).toISOString()) setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev)) - setSettingsOk(data.message) - setStartDateInput('') + setSettingsOk(`${data.message} La page d'accueil affiche désormais ces dates.`) + setStartDateInput(toDateTimeLocalValue(data.gameWindow.startDate)) } catch (err) { if (!handleAuthError(err)) setSettingsError(err.message) } finally { @@ -157,8 +170,9 @@ export default function Admin() { setEmployeeSaving(true) try { const data = await createEmployeeApi(employeeForm) - setEmployeeOk(`Compte employé créé pour ${data.employee.firstname} ${data.employee.lastname} (${data.employee.email}).`) - setEmployeeForm({ firstname: '', lastname: '', email: '', password: '' }) + setEmployeeOk(`Compte créé pour ${data.employee.firstname} ${data.employee.lastname} : un email avec un mot de passe provisoire a été envoyé à ${data.employee.email}.`) + setEmployeeForm({ firstname: '', lastname: '', email: '' }) + setEmployeesVersion((v) => v + 1) } catch (err) { if (!handleAuthError(err)) setEmployeeError(err.message) } finally { @@ -180,6 +194,15 @@ export default function Admin() { const winnerUser = winner?.winner?.user + // Aperçu en direct des dates publiques pour la date saisie. + const startInputDate = startDateInput ? new Date(startDateInput) : null + const startPreview = startInputDate && !Number.isNaN(startInputDate.getTime()) + ? gameDatesFromStart(startInputDate) + : null + const savedStart = stats?.gameWindow?.startDate + const startPreviewUnsaved = Boolean(startPreview) && + (!savedStart || toDateTimeLocalValue(savedStart) !== startDateInput) + return (
@@ -236,6 +259,17 @@ export default function Admin() { + {startPreview && ( +

+ Affiché sur la page d'accueil :{' '} + + Du {formatRange(startPreview.start, startPreview.lastPlayDay, { padDay: true })} + + , retrait des lots jusqu'au {formatDay(startPreview.lastClaimDay)} + {startPreviewUnsaved && (non enregistré)} +

+ )} +

Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.

@@ -392,6 +426,7 @@ export default function Admin() {

Créer un compte employé

Donne accès à l'espace employé (remise des lots en boutique). L'admin ne gère pas les remises lui-même. + L'employé reçoit par email un mot de passe provisoire, qu'il devra changer à sa première connexion.

@@ -428,19 +463,6 @@ export default function Admin() { className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
-
- - setEmployeeForm({ ...employeeForm, password: e.target.value })} - required - minLength={8} - placeholder="8 caractères minimum" - className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" - /> -
+ {/* ── Employés et remises des lots ── */} + + {/* ── Tirage au sort ── */}

Grand tirage au sort

diff --git a/frontend/src/pages/AuthCallback.jsx b/frontend/src/pages/AuthCallback.jsx index 16f6e7f..4843c7e 100644 --- a/frontend/src/pages/AuthCallback.jsx +++ b/frontend/src/pages/AuthCallback.jsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react' import { useNavigate, useSearchParams, Link } from 'react-router-dom' import { useAuth } from '../context/AuthContext' +import { homeFor } from '../config/roles' // Point d'arrivée après un aller-retour OAuth : le backend nous renvoie ici // avec le JWT et le profil encodés dans l'URL. @@ -40,10 +41,7 @@ export default function AuthCallback() { handled.current = true login(user, token) - - if (user.role === 'ADMIN') navigate('/admin', { replace: true }) - else if (user.role === 'EMPLOYEE') navigate('/employe', { replace: true }) - else navigate('/mes-gains', { replace: true }) + navigate(homeFor(user), { replace: true }) }, [user, token, login, navigate]) if (error) { diff --git a/frontend/src/pages/Connexion.jsx b/frontend/src/pages/Connexion.jsx index 9d3613e..78763f6 100644 --- a/frontend/src/pages/Connexion.jsx +++ b/frontend/src/pages/Connexion.jsx @@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext' import usePageMeta from '../hooks/usePageMeta' import Captcha from '../components/Captcha' import { CAPTCHA_ENABLED } from '../config/captcha' +import { homeFor } from '../config/roles' export default function Connexion() { usePageMeta({ @@ -39,9 +40,9 @@ export default function Connexion() { try { const { token, user } = await loginApi({ ...form, captchaToken }) login(user, token) - if (user.role === 'ADMIN') navigate('/admin') - else if (user.role === 'EMPLOYEE') navigate('/employe') - else navigate('/mes-gains') + // Un employé qui se connecte avec son mot de passe provisoire est + // envoyé directement sur le choix de son nouveau mot de passe. + navigate(homeFor(user)) } catch (err) { setError(err.message) // Le jeton a été consommé par le backend : on en redemande un. @@ -119,9 +120,13 @@ export default function Connexion() {
- +
diff --git a/frontend/src/pages/DefinirMotDePasse.jsx b/frontend/src/pages/DefinirMotDePasse.jsx index 0fab51b..8c11c7a 100644 --- a/frontend/src/pages/DefinirMotDePasse.jsx +++ b/frontend/src/pages/DefinirMotDePasse.jsx @@ -1,6 +1,8 @@ import { useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { resetPasswordApi } from '../services/auth' +import PasswordRules from '../components/PasswordRules' +import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules' // Sert les deux parcours qui passent par un jeton envoyé par email : // la définition initiale du mot de passe après une inscription Google, @@ -21,8 +23,8 @@ export default function DefinirMotDePasse() { e.preventDefault() setError('') - if (form.password.length < 8) { - setError('Le mot de passe doit contenir au moins 8 caractères.') + if (!isPasswordValid(form.password)) { + setError(PASSWORD_RULES_ERROR) return } if (form.password !== form.confirmPassword) { @@ -58,15 +60,22 @@ export default function DefinirMotDePasse() { return (
-

Mot de passe

+

Nouveau mot de passe

+ {/* Texte neutre : la page sert au mot de passe oublié comme au + premier mot de passe d'un compte Google. */}

- Choisissez un mot de passe pour accéder à votre compte sans passer par Google. + Choisissez le mot de passe que vous utiliserez pour vous connecter avec votre adresse email.

{error && ( -
+
{error} + {/invalide ou expiré/i.test(error) && ( + + Demander un nouveau lien + + )}
)} @@ -82,10 +91,12 @@ export default function DefinirMotDePasse() { value={form.password} onChange={handleChange} required - minLength={8} - placeholder="8 caractères minimum" + autoComplete="new-password" + aria-describedby="password-rules" + placeholder="Votre nouveau mot de passe" className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" /> +
diff --git a/frontend/src/pages/Inscription.jsx b/frontend/src/pages/Inscription.jsx index d4df572..163b892 100644 --- a/frontend/src/pages/Inscription.jsx +++ b/frontend/src/pages/Inscription.jsx @@ -5,6 +5,8 @@ import { useAuth } from '../context/AuthContext' import usePageMeta from '../hooks/usePageMeta' import Captcha from '../components/Captcha' import { CAPTCHA_ENABLED } from '../config/captcha' +import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules' +import PasswordRules from '../components/PasswordRules' // Une naissance ne peut pas être dans le futur : borne haute du sélecteur. const AUJOURD_HUI = new Date().toISOString().split('T')[0] @@ -42,6 +44,7 @@ export default function Inscription() { lastName: '', firstName: '', password: '', + confirmPassword: '', birthdate: '', gender: '', }) @@ -63,6 +66,9 @@ export default function Inscription() { const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value }) + // Signalé dès que la confirmation est saisie, sans attendre l'envoi. + const passwordMismatch = form.confirmPassword !== '' && form.password !== form.confirmPassword + // Même route que la connexion : le provider crée le compte à la première venue. const handleOAuthSignup = (provider) => { const apiUrl = import.meta.env.VITE_API_URL || '' @@ -79,6 +85,16 @@ export default function Inscription() { return } + if (!isPasswordValid(form.password)) { + setError(PASSWORD_RULES_ERROR) + return + } + + if (form.password !== form.confirmPassword) { + setError('Les deux mots de passe ne correspondent pas.') + return + } + setLoading(true) try { await registerApi({ @@ -86,6 +102,7 @@ export default function Inscription() { firstname: form.firstName, lastname: form.lastName, password: form.password, + confirmPassword: form.confirmPassword, birthdate: form.birthdate, gender: form.gender, marketingOptIn, @@ -256,6 +273,7 @@ export default function Inscription() {
+ +
+ +
+ + + {passwordMismatch && ( +

Les deux mots de passe ne correspondent pas.

+ )}

+ {name === 'newPassword' && ( + + )} ))} diff --git a/frontend/src/services/admin.js b/frontend/src/services/admin.js index 1228ce3..ca32afc 100644 --- a/frontend/src/services/admin.js +++ b/frontend/src/services/admin.js @@ -61,11 +61,34 @@ export async function getWinnerApi() { return data } -export async function createEmployeeApi({ firstname, lastname, email, password }) { +export async function getEmployeesApi() { + const res = await fetch(`${BASE}/employees`, { headers: authHeaders() }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || 'Erreur lors du chargement des employés.', res.status) + return data +} + +export async function getEmployeeDeliveriesApi(employeeId) { + const res = await fetch(`${BASE}/employees/${employeeId}/deliveries`, { headers: authHeaders() }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || 'Erreur lors du chargement des remises.', res.status) + return data +} + +export async function getDeliveryByTicketCodeApi(code) { + const res = await fetch(`${BASE}/deliveries/${encodeURIComponent(code.trim())}`, { headers: authHeaders() }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || 'Erreur lors de la recherche du ticket.', res.status) + return data +} + +// Pas de mot de passe : le serveur en génère un provisoire et l'envoie par +// email à l'employé. +export async function createEmployeeApi({ firstname, lastname, email }) { const res = await fetch(`${BASE}/employees`, { method: 'POST', headers: authHeaders(), - body: JSON.stringify({ firstname, lastname, email, password }), + body: JSON.stringify({ firstname, lastname, email }), }) const data = await res.json() if (!res.ok) throw apiError(data.message || "Erreur lors de la création de l'employé.", res.status) diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js index 7537c9c..6c23f72 100644 --- a/frontend/src/services/auth.js +++ b/frontend/src/services/auth.js @@ -23,6 +23,7 @@ export async function registerApi({ firstname, lastname, password, + confirmPassword, birthdate, gender, marketingOptIn, @@ -38,6 +39,7 @@ export async function registerApi({ firstname, lastname, password, + confirmPassword, birthdate: birthdate || undefined, gender: gender || undefined, marketingOptIn: marketingOptIn ?? undefined, @@ -66,11 +68,23 @@ export async function verifyCodeApi({ email, code }) { return data } -export async function forgotPasswordApi(email) { +export async function forgotPasswordApi({ email, captchaToken }) { const res = await fetch(`${BASE}/forgot-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), + body: JSON.stringify({ email, captchaToken: captchaToken || undefined }), + }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status) + return data +} + +// Utilisateur connecté : lien envoyé à l'adresse de son compte. +export async function sendPasswordSetupLinkApi() { + const token = localStorage.getItem('token') + const res = await fetch(`${BASE}/password-setup-link`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, }) const data = await res.json() if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status) @@ -109,6 +123,21 @@ export async function deleteAccountApi() { return data } +export async function setInitialPasswordApi({ password, confirmPassword }) { + const token = localStorage.getItem('token') + const res = await fetch(`${BASE}/initial-password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ password, confirmPassword }), + }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || "Erreur lors de l'enregistrement du mot de passe.", res.status) + return data +} + export async function changePasswordApi({ currentPassword, newPassword }) { const token = localStorage.getItem('token') const res = await fetch(`${BASE}/change-password`, { diff --git a/frontend/src/services/game.js b/frontend/src/services/game.js index fc81d9c..2126aa1 100644 --- a/frontend/src/services/game.js +++ b/frontend/src/services/game.js @@ -8,6 +8,14 @@ function apiError(message, status) { return err } +// Public : dates du jeu-concours fixées par l'admin. +export async function getGameWindowApi() { + const res = await fetch(`${BASE}/window`) + const data = await res.json() + if (!res.ok) throw apiError(data.message || 'Erreur lors de la lecture des dates du jeu.', res.status) + return data +} + export async function myGainsApi() { const token = localStorage.getItem('token') const res = await fetch(`${BASE}/my-gains`, { diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js index d0879b7..83b2a45 100644 --- a/frontend/src/setupTests.js +++ b/frontend/src/setupTests.js @@ -2,6 +2,9 @@ import '@testing-library/jest-dom' import { afterEach } from 'vitest' import { cleanup } from '@testing-library/react' +// jsdom n'implémente pas le défilement, présent dans tous les navigateurs. +Element.prototype.scrollIntoView ??= function scrollIntoView() {} + afterEach(() => { cleanup() localStorage.clear() diff --git a/frontend/src/test/fonctionnel/Accueil.test.jsx b/frontend/src/test/fonctionnel/Accueil.test.jsx new file mode 100644 index 0000000..e27ba43 --- /dev/null +++ b/frontend/src/test/fonctionnel/Accueil.test.jsx @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import Accueil from '../../pages/Accueil' +import { getGameWindowApi } from '../../services/game' +import { renderWithProviders } from '../renderWithProviders' + +vi.mock('../../services/game', () => ({ + getGameWindowApi: vi.fn(), +})) + +describe('Accueil - dates du jeu-concours', () => { + beforeEach(() => vi.clearAllMocks()) + + it("affiche les dates fixées dans le tableau de bord admin", async () => { + getGameWindowApi.mockResolvedValue({ + configured: true, + phase: 'SCHEDULED', + startDate: '2026-11-14T23:00:00.000Z', + participationDeadline: '2026-12-14T23:00:00.000Z', + claimDeadline: '2027-01-13T23:00:00.000Z', + }) + + renderWithProviders(, { route: '/accueil' }) + + expect(await screen.findByText('Du 15 novembre au 14 décembre 2026')).toBeInTheDocument() + expect(screen.getByText(/ouverte du 15 novembre au 14 décembre 2026 inclus/)).toBeInTheDocument() + expect(screen.getByText(/retirer jusqu'au 13 janvier 2027/)).toBeInTheDocument() + expect(document.title).toBe('Jeu-concours 100 % Gagnant du 15 novembre au 14 décembre | Thé Tip Top') + }) + + it("affiche les dates par défaut (2 → 31 octobre 2026) si l'API ne répond pas", async () => { + getGameWindowApi.mockRejectedValue(new Error('réseau')) + + renderWithProviders(, { route: '/accueil' }) + + expect(screen.getByText('Du 02 au 31 octobre 2026')).toBeInTheDocument() + expect(screen.getByText(/retirer jusqu'au 30 novembre 2026/)).toBeInTheDocument() + await vi.waitFor(() => expect(getGameWindowApi).toHaveBeenCalled()) + expect(screen.getByText('Du 02 au 31 octobre 2026')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/fonctionnel/DefinirMotDePasse.test.jsx b/frontend/src/test/fonctionnel/DefinirMotDePasse.test.jsx new file mode 100644 index 0000000..faf99b4 --- /dev/null +++ b/frontend/src/test/fonctionnel/DefinirMotDePasse.test.jsx @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import DefinirMotDePasse from '../../pages/DefinirMotDePasse' +import { resetPasswordApi } from '../../services/auth' +import { renderWithProviders } from '../renderWithProviders' + +vi.mock('../../services/auth', () => ({ + resetPasswordApi: vi.fn(), +})) + +async function remplir(password, confirmPassword = password) { + await userEvent.type(screen.getByLabelText(/^nouveau mot de passe/i), password) + await userEvent.type(screen.getByLabelText(/^confirmer/i), confirmPassword) + await userEvent.click(screen.getByRole('button', { name: /enregistrer/i })) +} + +describe('DefinirMotDePasse', () => { + beforeEach(() => vi.clearAllMocks()) + + it('affiche les règles du mot de passe', () => { + const { container } = renderWithProviders(, { route: '/definir-mot-de-passe/abc' }) + + expect(container.querySelector('[data-rule="length"]')).toBeInTheDocument() + expect(container.querySelector('[data-rule="special"]')).toBeInTheDocument() + }) + + it("refuse un mot de passe sans caractère spécial sans appeler l'API", async () => { + renderWithProviders(, { route: '/definir-mot-de-passe/abc' }) + + await remplir('MonMotDePasse1') + + expect(screen.getByText(/dont un caractère spécial/i)).toBeInTheDocument() + expect(resetPasswordApi).not.toHaveBeenCalled() + }) + + it('propose de demander un nouveau lien quand celui-ci a expiré', async () => { + resetPasswordApi.mockRejectedValue(new Error('Lien de réinitialisation invalide ou expiré.')) + renderWithProviders(, { route: '/definir-mot-de-passe/abc' }) + + await remplir('MonMotDePasse1!') + + expect(await screen.findByRole('link', { name: /demander un nouveau lien/i })).toHaveAttribute('href', '/mot-de-passe-oublie') + }) + + it('envoie un mot de passe conforme', async () => { + resetPasswordApi.mockResolvedValue({ message: 'ok' }) + renderWithProviders(, { route: '/definir-mot-de-passe/abc' }) + + await remplir('MonMotDePasse1!') + + expect(resetPasswordApi).toHaveBeenCalledWith( + expect.objectContaining({ password: 'MonMotDePasse1!', confirmPassword: 'MonMotDePasse1!' }) + ) + }) +}) diff --git a/frontend/src/test/fonctionnel/EmployeeDeliveries.test.jsx b/frontend/src/test/fonctionnel/EmployeeDeliveries.test.jsx new file mode 100644 index 0000000..4e46bb7 --- /dev/null +++ b/frontend/src/test/fonctionnel/EmployeeDeliveries.test.jsx @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import EmployeeDeliveries from '../../components/admin/EmployeeDeliveries' +import { getEmployeesApi, getEmployeeDeliveriesApi, getDeliveryByTicketCodeApi } from '../../services/admin' + +vi.mock('../../services/admin', () => ({ + getEmployeesApi: vi.fn(), + getEmployeeDeliveriesApi: vi.fn(), + getDeliveryByTicketCodeApi: vi.fn(), +})) + +const MARC = { id: 10, firstname: 'Marc', lastname: 'Durand', email: 'marc@example.com', createdAt: '2026-09-01T00:00:00Z', mustChangePassword: false, deliveredCount: 1 } +const SOPHIE = { id: 11, firstname: 'Sophie', lastname: 'Bernard', email: 'sophie@example.com', createdAt: '2026-09-02T00:00:00Z', mustChangePassword: true, deliveredCount: 0 } + +function renderComponent() { + return render( false} />) +} + +describe('EmployeeDeliveries', () => { + beforeEach(() => { + vi.clearAllMocks() + getEmployeesApi.mockResolvedValue({ employees: [MARC, SOPHIE] }) + }) + + it('liste les employés et filtre par nom', async () => { + renderComponent() + + expect(await screen.findByRole('button', { name: 'Marc Durand' })).toBeInTheDocument() + expect(screen.getByText('Première connexion en attente')).toBeInTheDocument() + + await userEvent.type(screen.getByLabelText(/rechercher un employé/i), 'sophie') + + expect(screen.queryByRole('button', { name: 'Marc Durand' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Sophie Bernard' })).toBeInTheDocument() + }) + + it("affiche les lots remis quand on clique sur le nom d'un employé", async () => { + getEmployeeDeliveriesApi.mockResolvedValue({ + employee: MARC, + deliveries: [{ + claimId: 1, + ticketCode: 'A1B2C3D4E5', + lot: { name: 'Infuseur à thé', value: 39 }, + client: { id: 5, firstname: 'Chloé', lastname: 'Martin', email: 'chloe@example.com' }, + deliveredAt: '2026-09-10T14:30:00Z', + }], + }) + renderComponent() + + await userEvent.click(await screen.findByRole('button', { name: 'Marc Durand' })) + + expect(getEmployeeDeliveriesApi).toHaveBeenCalledWith(10) + expect(await screen.findByText('A1B2C3D4E5')).toBeInTheDocument() + expect(screen.getByText(/lots remis par marc durand/i)).toBeInTheDocument() + expect(screen.getByText('Infuseur à thé')).toBeInTheDocument() + }) + + it("retrouve l'employé et la date de remise à partir du numéro de ticket", async () => { + getDeliveryByTicketCodeApi.mockResolvedValue({ + ticketCode: 'A1B2C3D4E5', + lot: { name: 'Infuseur à thé', value: 39 }, + status: 'DELIVERED', + playedAt: '2026-09-01T10:00:00Z', + client: { id: 5, firstname: 'Chloé', lastname: 'Martin', email: 'chloe@example.com' }, + deliveredBy: { id: 10, firstname: 'Marc', lastname: 'Durand', email: 'marc@example.com' }, + deliveredAt: '2026-09-10T14:30:00Z', + }) + getEmployeeDeliveriesApi.mockResolvedValue({ employee: MARC, deliveries: [] }) + renderComponent() + + await userEvent.type(screen.getByLabelText(/numéro du ticket/i), 'a1b2c3d4e5') + await userEvent.click(screen.getByRole('button', { name: /^rechercher$/i })) + + const resultat = await screen.findByTestId('ticket-result') + expect(getDeliveryByTicketCodeApi).toHaveBeenCalledWith('a1b2c3d4e5') + expect(within(resultat).getByText('Lot remis')).toBeInTheDocument() + expect(within(resultat).getByText(/remis par/i)).toHaveTextContent(new Date('2026-09-10T14:30:00Z').toLocaleString('fr-FR', { + day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', + })) + + // Le nom de l'employé mène à la liste de ses remises. + await userEvent.click(within(resultat).getByRole('button', { name: 'Marc Durand' })) + expect(getEmployeeDeliveriesApi).toHaveBeenCalledWith(10) + expect(await screen.findByText(/n'a encore remis aucun lot/i)).toBeInTheDocument() + }) + + it("affiche l'erreur quand le ticket n'existe pas", async () => { + getDeliveryByTicketCodeApi.mockRejectedValue(new Error('Aucun ticket ne correspond à ce numéro.')) + renderComponent() + + await userEvent.type(screen.getByLabelText(/numéro du ticket/i), 'INCONNU') + await userEvent.click(screen.getByRole('button', { name: /^rechercher$/i })) + + expect(await screen.findByText('Aucun ticket ne correspond à ce numéro.')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/fonctionnel/Inscription.test.jsx b/frontend/src/test/fonctionnel/Inscription.test.jsx index e774776..098b433 100644 --- a/frontend/src/test/fonctionnel/Inscription.test.jsx +++ b/frontend/src/test/fonctionnel/Inscription.test.jsx @@ -10,13 +10,21 @@ vi.mock('../../services/auth', () => ({ verifyCodeApi: vi.fn(), })) -// Remplit et soumet le formulaire d'inscription (étape 1). -async function remplirEtSoumettre() { +// Remplit les champs obligatoires (la date de naissance l'est depuis la règle +// des 18 ans), sans soumettre. +async function remplirChampsObligatoires({ password = 'MotDePasse123!', confirmPassword = password } = {}) { await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com') await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont') await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean') - await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!') + await userEvent.type(screen.getByLabelText(/date de naissance/i), '1990-05-14') + await userEvent.type(screen.getByLabelText(/^mot de passe/i), password) + await userEvent.type(screen.getByLabelText(/confirmer le mot de passe/i), confirmPassword) await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i })) +} + +// Remplit et soumet le formulaire d'inscription (étape 1). +async function remplirEtSoumettre(options) { + await remplirChampsObligatoires(options) await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i })) } @@ -41,6 +49,50 @@ describe('Inscription', () => { expect(screen.getByLabelText(/^nom/i)).toBeInTheDocument() expect(screen.getByLabelText(/prénom/i)).toBeInTheDocument() expect(screen.getByLabelText(/^mot de passe/i)).toBeInTheDocument() + expect(screen.getByLabelText(/confirmer le mot de passe/i)).toBeInTheDocument() + }) + + it('coche les règles du mot de passe au fil de la saisie', async () => { + const { container } = renderWithProviders(, { route: '/inscription' }) + const regle = (id) => container.querySelector(`[data-rule="${id}"]`) + + expect(regle('length')).toHaveAttribute('data-valid', 'false') + expect(regle('special')).toHaveAttribute('data-valid', 'false') + + await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'abcdefgh') + expect(regle('length')).toHaveAttribute('data-valid', 'true') + expect(regle('special')).toHaveAttribute('data-valid', 'false') + + await userEvent.type(screen.getByLabelText(/^mot de passe/i), '!') + expect(regle('special')).toHaveAttribute('data-valid', 'true') + }) + + it("refuse un mot de passe sans caractère spécial sans appeler l'API", async () => { + renderWithProviders(, { route: '/inscription' }) + await remplirEtSoumettre({ password: 'MotDePasse123' }) + + expect(screen.getByText(/au moins 8 caractères, dont un caractère spécial/i)).toBeInTheDocument() + expect(registerApi).not.toHaveBeenCalled() + }) + + it("refuse un mot de passe trop court sans appeler l'API", async () => { + renderWithProviders(, { route: '/inscription' }) + await remplirEtSoumettre({ password: 'Ab1!xyz' }) + + expect(screen.getByText(/au moins 8 caractères, dont un caractère spécial/i)).toBeInTheDocument() + expect(registerApi).not.toHaveBeenCalled() + }) + + it("signale une confirmation différente et bloque l'inscription", async () => { + renderWithProviders(, { route: '/inscription' }) + await remplirChampsObligatoires({ password: 'MotDePasse123!', confirmPassword: 'MotDePasse124!' }) + + expect(screen.getByLabelText(/confirmer le mot de passe/i)).toHaveAttribute('aria-invalid', 'true') + + await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i })) + + expect(screen.getAllByText(/ne correspondent pas/i).length).toBeGreaterThan(0) + expect(registerApi).not.toHaveBeenCalled() }) it("désactive le bouton tant que les conditions ne sont pas acceptées", async () => { @@ -64,9 +116,11 @@ describe('Inscription', () => { firstname: 'Jean', lastname: 'Dupont', password: 'MotDePasse123!', - birthdate: '', + confirmPassword: 'MotDePasse123!', + birthdate: '1990-05-14', gender: '', marketingOptIn: false, + captchaToken: '', }) expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument() expect(screen.getByText('jean@example.com')).toBeInTheDocument() @@ -83,6 +137,7 @@ describe('Inscription', () => { await userEvent.type(screen.getByLabelText(/date de naissance/i), '1990-05-14') await userEvent.selectOptions(screen.getByLabelText(/genre/i), 'Femme') await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!') + await userEvent.type(screen.getByLabelText(/confirmer le mot de passe/i), 'MotDePasse123!') await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i })) await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i })) @@ -99,13 +154,8 @@ describe('Inscription', () => { const marketing = screen.getByRole('checkbox', { name: /offres et actualités/i }) expect(marketing).not.toBeChecked() - await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com') - await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont') - await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean') - await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!') await userEvent.click(marketing) - await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i })) - await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i })) + await remplirEtSoumettre() expect(registerApi).toHaveBeenCalledWith( expect.objectContaining({ marketingOptIn: true }) diff --git a/frontend/src/test/fonctionnel/MotDePasseOublie.test.jsx b/frontend/src/test/fonctionnel/MotDePasseOublie.test.jsx new file mode 100644 index 0000000..c90149f --- /dev/null +++ b/frontend/src/test/fonctionnel/MotDePasseOublie.test.jsx @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import MotDePasseOublie from '../../pages/MotDePasseOublie' +import Connexion from '../../pages/Connexion' +import { forgotPasswordApi } from '../../services/auth' +import { renderWithProviders } from '../renderWithProviders' + +vi.mock('../../services/auth', () => ({ + forgotPasswordApi: vi.fn(), + loginApi: vi.fn(), +})) + +describe('MotDePasseOublie', () => { + beforeEach(() => vi.clearAllMocks()) + + it("envoie la demande et affiche la confirmation neutre", async () => { + forgotPasswordApi.mockResolvedValue({ message: 'Si un compte existe avec cet email, un lien de réinitialisation vient de lui être envoyé.' }) + renderWithProviders(, { route: '/mot-de-passe-oublie' }) + + await userEvent.type(screen.getByLabelText(/adresse email/i), 'lea@example.com') + await userEvent.click(screen.getByRole('button', { name: /recevoir le lien/i })) + + expect(forgotPasswordApi).toHaveBeenCalledWith({ email: 'lea@example.com', captchaToken: '' }) + expect(await screen.findByRole('status')).toHaveTextContent(/si un compte existe/i) + expect(screen.getByRole('link', { name: /retour à la connexion/i })).toHaveAttribute('href', '/connexion') + }) + + it("affiche l'erreur renvoyée par l'API", async () => { + forgotPasswordApi.mockRejectedValue(new Error('Trop de tentatives, réessayez dans quelques minutes.')) + renderWithProviders(, { route: '/mot-de-passe-oublie' }) + + await userEvent.type(screen.getByLabelText(/adresse email/i), 'lea@example.com') + await userEvent.click(screen.getByRole('button', { name: /recevoir le lien/i })) + + expect(await screen.findByRole('alert')).toHaveTextContent(/trop de tentatives/i) + }) + + it("le lien de la page de connexion y mène en reprenant l'email saisi", async () => { + renderWithProviders(, { + route: '/connexion', + routes: { '/mot-de-passe-oublie': }, + }) + + await userEvent.type(screen.getByLabelText(/adresse email/i), 'lea@example.com') + await userEvent.click(screen.getByRole('link', { name: /mot de passe oublié/i })) + + expect(await screen.findByRole('heading', { name: /mot de passe oublié/i })).toBeInTheDocument() + expect(screen.getByLabelText(/adresse email/i)).toHaveValue('lea@example.com') + }) +}) diff --git a/frontend/src/test/fonctionnel/NouveauMotDePasse.test.jsx b/frontend/src/test/fonctionnel/NouveauMotDePasse.test.jsx new file mode 100644 index 0000000..37329eb --- /dev/null +++ b/frontend/src/test/fonctionnel/NouveauMotDePasse.test.jsx @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import NouveauMotDePasse from '../../pages/NouveauMotDePasse' +import { setInitialPasswordApi } from '../../services/auth' +import { renderWithProviders } from '../renderWithProviders' + +vi.mock('../../services/auth', () => ({ + setInitialPasswordApi: vi.fn(), +})) + +const EMPLOYE = { id: 3, firstname: 'Marc', email: 'marc@example.com', role: 'EMPLOYEE', mustChangePassword: true } + +async function remplir(password, confirmPassword) { + await userEvent.type(screen.getByLabelText(/^nouveau mot de passe/i), password) + await userEvent.type(screen.getByLabelText(/^confirmer/i), confirmPassword) + await userEvent.click(screen.getByRole('button', { name: /enregistrer/i })) +} + +describe('NouveauMotDePasse', () => { + beforeEach(() => vi.clearAllMocks()) + + it("refuse deux mots de passe différents sans appeler l'API", async () => { + renderWithProviders(, { route: '/nouveau-mot-de-passe', user: EMPLOYE }) + + await remplir('NouveauMdp123!', 'AutreMdp456!') + + expect(screen.getByRole('alert')).toHaveTextContent(/ne correspondent pas/i) + expect(setInitialPasswordApi).not.toHaveBeenCalled() + }) + + it("enregistre le mot de passe puis ouvre l'espace employé", async () => { + setInitialPasswordApi.mockResolvedValue({ message: 'ok' }) + renderWithProviders(, { + route: '/nouveau-mot-de-passe', + user: EMPLOYE, + routes: { '/employe':

Page Employé

}, + }) + + await remplir('NouveauMdp123!', 'NouveauMdp123!') + + expect(await screen.findByText('Page Employé')).toBeInTheDocument() + expect(setInitialPasswordApi).toHaveBeenCalledWith({ password: 'NouveauMdp123!', confirmPassword: 'NouveauMdp123!' }) + expect(JSON.parse(localStorage.getItem('user')).mustChangePassword).toBe(false) + }) + + it("affiche l'erreur renvoyée par l'API", async () => { + setInitialPasswordApi.mockRejectedValue(new Error('Choisissez un mot de passe différent du mot de passe provisoire.')) + renderWithProviders(, { route: '/nouveau-mot-de-passe', user: EMPLOYE }) + + await remplir('Provisoire12!', 'Provisoire12!') + + expect(await screen.findByText(/différent du mot de passe provisoire/i)).toBeInTheDocument() + }) + + it("refuse un mot de passe sans caractère spécial sans appeler l'API", async () => { + renderWithProviders(, { route: '/nouveau-mot-de-passe', user: EMPLOYE }) + + await remplir('NouveauMdp123', 'NouveauMdp123') + + expect(screen.getByRole('alert')).toHaveTextContent(/caractère spécial/i) + expect(setInitialPasswordApi).not.toHaveBeenCalled() + }) + + it("renvoie vers son espace un utilisateur qui n'a pas de mot de passe provisoire", () => { + renderWithProviders(, { + route: '/nouveau-mot-de-passe', + user: { ...EMPLOYE, mustChangePassword: false }, + routes: { '/employe':

Page Employé

}, + }) + + expect(screen.getByText('Page Employé')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/fonctionnel/ProtectedRoute.test.jsx b/frontend/src/test/fonctionnel/ProtectedRoute.test.jsx index b33fc34..c13b413 100644 --- a/frontend/src/test/fonctionnel/ProtectedRoute.test.jsx +++ b/frontend/src/test/fonctionnel/ProtectedRoute.test.jsx @@ -77,4 +77,20 @@ describe('ProtectedRoute', () => { expect(screen.getByText('Page Employé')).toBeInTheDocument() }) + + it('impose le choix du mot de passe tant que le provisoire n\'est pas remplacé', () => { + renderWithProviders( + +

Espace employé

+
, + { + route: '/employe', + user: { id: 3, email: 'employe@example.com', role: 'EMPLOYEE', mustChangePassword: true }, + routes: { '/nouveau-mot-de-passe':

Page Nouveau mot de passe

}, + } + ) + + expect(screen.getByText('Page Nouveau mot de passe')).toBeInTheDocument() + expect(screen.queryByText('Espace employé')).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/test/unit/gameDates.test.js b/frontend/src/test/unit/gameDates.test.js new file mode 100644 index 0000000..7b3ff1e --- /dev/null +++ b/frontend/src/test/unit/gameDates.test.js @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { toGameDates, gameDatesFromStart, formatDay, formatRange } from '../../utils/gameDates' + +describe('gameDates', () => { + it('retombe sur le 2 → 31 octobre 2026 quand le jeu n\'est pas configuré', () => { + for (const gameWindow of [null, { configured: false, startDate: null }]) { + const { start, lastPlayDay, lastClaimDay } = toGameDates(gameWindow) + expect(formatRange(start, lastPlayDay, { padDay: true })).toBe('02 au 31 octobre 2026') + expect(formatDay(lastClaimDay)).toBe('30 novembre 2026') + } + }) + + it("utilise les échéances renvoyées par l'API", () => { + const dates = toGameDates({ + configured: true, + startDate: '2026-11-14T23:00:00.000Z', // 15 novembre 00:00 à Paris + participationDeadline: '2026-12-14T23:00:00.000Z', + claimDeadline: '2027-01-13T23:00:00.000Z', + }) + + expect(formatRange(dates.start, dates.lastPlayDay)).toBe('15 novembre au 14 décembre 2026') + expect(formatDay(dates.lastClaimDay)).toBe('13 janvier 2027') + }) + + it("calcule l'aperçu admin à partir de la seule date de début, en heure de Paris", () => { + const dates = gameDatesFromStart(new Date('2026-10-02T00:00:00+02:00')) + expect(formatRange(dates.start, dates.lastPlayDay)).toBe('2 au 31 octobre 2026') + expect(formatDay(dates.lastClaimDay)).toBe('30 novembre 2026') + }) + + it('formate une période à cheval sur deux années', () => { + // Début à 10 h : l'échéance tombe le 19 janvier à 10 h, dernier jour jouable. + const dates = gameDatesFromStart(new Date('2026-12-20T10:00:00+01:00')) + expect(formatRange(dates.start, dates.lastPlayDay)).toBe('20 décembre 2026 au 19 janvier 2027') + expect(formatRange(dates.start, dates.lastPlayDay, { withYear: false })).toBe('20 décembre au 19 janvier') + }) +}) diff --git a/frontend/src/utils/gameDates.js b/frontend/src/utils/gameDates.js new file mode 100644 index 0000000..ef4c008 --- /dev/null +++ b/frontend/src/utils/gameDates.js @@ -0,0 +1,80 @@ +// Dates du jeu-concours affichées au public. Elles viennent de l'API (date de +// début fixée par l'admin) ; en attendant la réponse, ou si le jeu n'est pas +// configuré, on affiche les dates prévues par le règlement : 2 → 31 octobre 2026. + +const TIME_ZONE = 'Europe/Paris' +const DAY_MS = 24 * 60 * 60 * 1000 + +// Mêmes durées que backend/src/utils/gameWindow.js. +const GAME_DURATION_DAYS = 30 +const CLAIM_EXTENSION_DAYS = 30 + +export const DEFAULT_START_DATE = '2026-10-02T00:00:00+02:00' + +function windowFromStart(startDate) { + const start = new Date(startDate) + return { + start, + participationDeadline: new Date(start.getTime() + GAME_DURATION_DAYS * DAY_MS), + claimDeadline: new Date(start.getTime() + (GAME_DURATION_DAYS + CLAIM_EXTENSION_DAYS) * DAY_MS), + } +} + +// Les échéances sont des instants exclusifs (début + 30 j) : le dernier jour +// affiché est celui qui précède immédiatement l'échéance. +function lastDayBefore(deadline) { + return new Date(deadline.getTime() - 1) +} + +export function toGameDates(gameWindow) { + const source = gameWindow?.configured && gameWindow.startDate + ? { + start: new Date(gameWindow.startDate), + participationDeadline: new Date(gameWindow.participationDeadline), + claimDeadline: new Date(gameWindow.claimDeadline), + } + : windowFromStart(DEFAULT_START_DATE) + + return { + start: source.start, + lastPlayDay: lastDayBefore(source.participationDeadline), + lastClaimDay: lastDayBefore(source.claimDeadline), + } +} + +// Même calcul à partir d'une date de début seule (aperçu dans l'admin). +export function gameDatesFromStart(startDate) { + return toGameDates({ configured: true, startDate, ...windowFromStart(startDate) }) +} + +function parts(date) { + const values = {} + new Intl.DateTimeFormat('fr-FR', { timeZone: TIME_ZONE, day: 'numeric', month: 'long', year: 'numeric' }) + .formatToParts(date) + .forEach(({ type, value }) => { values[type] = value }) + return values +} + +// « 30 novembre 2026 » +export function formatDay(date) { + const { day, month, year } = parts(date) + return `${day} ${month} ${year}` +} + +// « 2 au 31 octobre 2026 », « 15 octobre au 13 novembre 2026 », +// « 20 décembre 2026 au 18 janvier 2027 ». Sans « du » : l'appelant choisit +// la majuscule. `padDay` écrit « 02 », `withYear: false` omet l'année. +export function formatRange(start, end, { padDay = false, withYear = true } = {}) { + const a = parts(start) + const b = parts(end) + const startDay = padDay ? a.day.padStart(2, '0') : a.day + const year = withYear ? ` ${b.year}` : '' + + if (a.year !== b.year) { + return `${startDay} ${a.month}${withYear ? ` ${a.year}` : ''} au ${b.day} ${b.month}${year}` + } + if (a.month !== b.month) { + return `${startDay} ${a.month} au ${b.day} ${b.month}${year}` + } + return `${startDay} au ${b.day} ${b.month}${year}` +} diff --git a/frontend/src/utils/passwordRules.js b/frontend/src/utils/passwordRules.js new file mode 100644 index 0000000..ad33a51 --- /dev/null +++ b/frontend/src/utils/passwordRules.js @@ -0,0 +1,14 @@ +// Règle de mot de passe, identique au backend +// (backend/src/validations/password.validation.js) : plus de 7 caractères et +// au moins un caractère spécial (ni lettre, accentuée comprise, ni chiffre, +// ni espace). +export const PASSWORD_RULES = [ + { id: 'length', label: '8 caractères minimum', test: (password) => password.length >= 8 }, + { id: 'special', label: 'Au moins un caractère spécial (!, @, #, ?, …)', test: (password) => /[^\p{L}\p{N}\s]/u.test(password) }, +] + +export function isPasswordValid(password) { + return PASSWORD_RULES.every((rule) => rule.test(password)) +} + +export const PASSWORD_RULES_ERROR = 'Le mot de passe doit contenir au moins 8 caractères, dont un caractère spécial.'