changement
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "mustChangePassword" BOOLEAN NOT NULL DEFAULT false;
|
||||||
@@ -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"))
|
||||||
|
);
|
||||||
@@ -51,6 +51,11 @@ model User {
|
|||||||
resetPasswordToken String?
|
resetPasswordToken String?
|
||||||
resetPasswordExpires DateTime?
|
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)
|
provider AuthProvider @default(LOCAL)
|
||||||
providerId String?
|
providerId String?
|
||||||
avatarUrl String?
|
avatarUrl String?
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const prisma = require("../src/config/prisma");
|
const prisma = require("../src/config/prisma");
|
||||||
|
const { DEFAULT_START_DATE } = require("../src/utils/gameWindow");
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await prisma.lot.deleteMany();
|
await prisma.lot.deleteMany();
|
||||||
@@ -42,8 +43,8 @@ async function main() {
|
|||||||
|
|
||||||
const existingSettings = await prisma.gameSettings.findFirst();
|
const existingSettings = await prisma.gameSettings.findFirst();
|
||||||
if (!existingSettings) {
|
if (!existingSettings) {
|
||||||
await prisma.gameSettings.create({ data: {} });
|
await prisma.gameSettings.create({ data: { startDate: DEFAULT_START_DATE } });
|
||||||
console.log("Date de début du jeu-concours initialisée à aujourd'hui.");
|
console.log("Date de début du jeu-concours initialisée au 2 octobre 2026.");
|
||||||
} else {
|
} else {
|
||||||
console.log("Date de début du jeu-concours déjà définie, inchangée.");
|
console.log("Date de début du jeu-concours déjà définie, inchangée.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const crypto = require("crypto");
|
|||||||
const prisma = require("./prisma");
|
const prisma = require("./prisma");
|
||||||
const transporter = require("./mailer");
|
const transporter = require("./mailer");
|
||||||
const getFrontendUrl = require("../utils/frontendUrl");
|
const getFrontendUrl = require("../utils/frontendUrl");
|
||||||
|
const { normalizeEmail } = require("../validations/email.validation");
|
||||||
|
|
||||||
const PROVIDER_LABELS = { GOOGLE: "Google", FACEBOOK: "Facebook" };
|
const PROVIDER_LABELS = { GOOGLE: "Google", FACEBOOK: "Facebook" };
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ function verifyOAuthProfile(provider) {
|
|||||||
|
|
||||||
return async (accessToken, refreshToken, profile, done) => {
|
return async (accessToken, refreshToken, profile, done) => {
|
||||||
try {
|
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.
|
// Cas fréquent chez Facebook : compte ouvert avec un numéro de téléphone.
|
||||||
if (!email) {
|
if (!email) {
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ const bcrypt = require("bcrypt");
|
|||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const prisma = require("../config/prisma");
|
const prisma = require("../config/prisma");
|
||||||
const transporter = require("../config/mailer");
|
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+"];
|
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) => {
|
const createEmployee = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { firstname, lastname, email, password, birthdate, gender } = req.body;
|
const { firstname, lastname, email, birthdate, gender } = req.body;
|
||||||
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
const existingUser = await prisma.user.findUnique({
|
||||||
where: { email },
|
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({
|
const employee = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -192,6 +215,8 @@ const createEmployee = async (req, res) => {
|
|||||||
gender,
|
gender,
|
||||||
role: "EMPLOYEE",
|
role: "EMPLOYEE",
|
||||||
marketingOptIn: false,
|
marketingOptIn: false,
|
||||||
|
isEmailVerified: true,
|
||||||
|
mustChangePassword: true,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
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: `
|
||||||
|
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||||
|
<h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
|
||||||
|
<p>Un compte employé Thé Tip Top vient d'être créé pour vous.</p>
|
||||||
|
<p>Voici vos identifiants de première connexion :</p>
|
||||||
|
<p style="background:#f3f4f6;padding:12px 16px;border-radius:8px">
|
||||||
|
Email : <strong>${email}</strong><br />
|
||||||
|
Mot de passe provisoire : <strong style="font-family:monospace;font-size:16px">${tempPassword}</strong>
|
||||||
|
</p>
|
||||||
|
<p style="text-align:center;padding:20px 0">
|
||||||
|
<a href="${loginUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
|
||||||
|
Me connecter
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#888;font-size:13px">Dès votre première connexion, vous devrez
|
||||||
|
choisir un nouveau mot de passe. Le mot de passe provisoire ne fonctionnera
|
||||||
|
plus ensuite.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} 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({
|
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,
|
employee,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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 = {
|
module.exports = {
|
||||||
getStats,
|
getStats,
|
||||||
getUsers,
|
getUsers,
|
||||||
getEmailingContacts,
|
getEmailingContacts,
|
||||||
createEmployee,
|
createEmployee,
|
||||||
|
getEmployees,
|
||||||
|
getEmployeeDeliveries,
|
||||||
|
getDeliveryByTicketCode,
|
||||||
drawWinner,
|
drawWinner,
|
||||||
getGrandPrizeWinner,
|
getGrandPrizeWinner,
|
||||||
setGameSettings,
|
setGameSettings,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ const crypto = require("crypto");
|
|||||||
const prisma = require("../config/prisma");
|
const prisma = require("../config/prisma");
|
||||||
const transporter = require("../config/mailer");
|
const transporter = require("../config/mailer");
|
||||||
const getFrontendUrl = require("../utils/frontendUrl");
|
const getFrontendUrl = require("../utils/frontendUrl");
|
||||||
|
const { passwordRuleError } = require("../validations/password.validation");
|
||||||
|
const { normalizeEmail } = require("../validations/email.validation");
|
||||||
|
|
||||||
const register = async (req, res) => {
|
const register = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -211,6 +213,7 @@ const login = async (req, res) => {
|
|||||||
lastname: user.lastname,
|
lastname: user.lastname,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
mustChangePassword: user.mustChangePassword,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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: `
|
||||||
|
<h2>Réinitialisation du mot de passe</h2>
|
||||||
|
<p>Bonjour ${user.firstname},</p>
|
||||||
|
<p>Vous avez demandé à définir un nouveau mot de passe.</p>
|
||||||
|
<p>Cliquez sur le lien ci-dessous pour le choisir :</p>
|
||||||
|
<a href="${resetUrl}">Choisir mon nouveau mot de passe</a>
|
||||||
|
<p>Ce lien expire dans 30 minutes.</p>
|
||||||
|
<p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
const forgotPassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email } = req.body;
|
const email = normalizeEmail(req.body.email);
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return res.status(400).json({
|
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
|
// Sécurité : ne pas dire si l'email existe ou non
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(200).json({
|
return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE });
|
||||||
message: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetPasswordToken = crypto.randomBytes(32).toString("hex");
|
await sendPasswordResetEmail(user);
|
||||||
const resetPasswordExpires = new Date(Date.now() + 1000 * 60 * 30); // 30 min
|
|
||||||
|
|
||||||
await prisma.user.update({
|
return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE });
|
||||||
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: `
|
|
||||||
<h2>Réinitialisation du mot de passe</h2>
|
|
||||||
<p>Bonjour ${user.firstname},</p>
|
|
||||||
<p>Vous avez demandé la réinitialisation de votre mot de passe.</p>
|
|
||||||
<p>Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe :</p>
|
|
||||||
<a href="${resetUrl}">Réinitialiser mon mot de passe</a>
|
|
||||||
<p>Ce lien expire dans 30 minutes.</p>
|
|
||||||
<p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
message: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur forgotPassword:", error);
|
console.error("Erreur forgotPassword:", error);
|
||||||
return res.status(500).json({
|
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) => {
|
const resetPassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { token } = req.params;
|
const { token } = req.params;
|
||||||
const { password, confirmPassword } = req.body;
|
const { password, confirmPassword } = req.body;
|
||||||
|
|
||||||
if (!password || password.length < 8) {
|
const passwordError = passwordRuleError(password);
|
||||||
return res.status(400).json({
|
if (passwordError) {
|
||||||
message: "Le nouveau mot de passe doit contenir au moins 8 caractères.",
|
return res.status(400).json({ message: passwordError });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// La confirmation est vérifiée côté serveur aussi : le contrôle du
|
// 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,
|
password: hashedPassword,
|
||||||
resetPasswordToken: null,
|
resetPasswordToken: null,
|
||||||
resetPasswordExpires: 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) => {
|
const verifyCode = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email, code } = req.body;
|
const { code } = req.body;
|
||||||
|
const email = normalizeEmail(req.body.email);
|
||||||
|
|
||||||
if (!email || !code) {
|
if (!email || !code) {
|
||||||
return res.status(400).json({ message: "Email et code requis." });
|
return res.status(400).json({ message: "Email et code requis." });
|
||||||
@@ -424,6 +453,7 @@ const oauthCallback = (req, res) => {
|
|||||||
lastname: user.lastname,
|
lastname: user.lastname,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
mustChangePassword: user.mustChangePassword,
|
||||||
})
|
})
|
||||||
).toString("base64");
|
).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) => {
|
const changePassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { currentPassword, newPassword } = req.body;
|
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." });
|
return res.status(400).json({ message: "Les deux mots de passe sont requis." });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 8) {
|
const passwordError = passwordRuleError(newPassword);
|
||||||
return res.status(400).json({ message: "Le nouveau mot de passe doit faire au moins 8 caractères." });
|
if (passwordError) {
|
||||||
|
return res.status(400).json({ message: passwordError });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
@@ -536,7 +608,9 @@ module.exports = {
|
|||||||
verifyCode,
|
verifyCode,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
forgotPassword,
|
forgotPassword,
|
||||||
|
sendPasswordSetupLink,
|
||||||
changePassword,
|
changePassword,
|
||||||
|
setInitialPassword,
|
||||||
deleteAccount,
|
deleteAccount,
|
||||||
oauthCallback,
|
oauthCallback,
|
||||||
};
|
};
|
||||||
@@ -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 = {
|
module.exports = {
|
||||||
participate,
|
participate,
|
||||||
myGains,
|
myGains,
|
||||||
|
getPublicGameWindow,
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
const prisma = require("../config/prisma");
|
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) => {
|
const authMiddleware = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
@@ -25,6 +30,7 @@ const authMiddleware = async (req, res, next) => {
|
|||||||
role: true,
|
role: true,
|
||||||
provider: true,
|
provider: true,
|
||||||
password: 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
|
// 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
|
// un simple booléen, qui suffit au profil pour proposer « définir » ou
|
||||||
// « modifier » son mot de passe.
|
// « modifier » son mot de passe.
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ const {
|
|||||||
getUsers,
|
getUsers,
|
||||||
getEmailingContacts,
|
getEmailingContacts,
|
||||||
createEmployee,
|
createEmployee,
|
||||||
|
getEmployees,
|
||||||
|
getEmployeeDeliveries,
|
||||||
|
getDeliveryByTicketCode,
|
||||||
drawWinner,
|
drawWinner,
|
||||||
getGrandPrizeWinner,
|
getGrandPrizeWinner,
|
||||||
setGameSettings,
|
setGameSettings,
|
||||||
@@ -81,6 +84,73 @@ router.get("/emailing", getEmailingContacts);
|
|||||||
*/
|
*/
|
||||||
router.post("/employees", validate(createEmployeeSchema), createEmployee);
|
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
|
* @swagger
|
||||||
* /api/admin/draw:
|
* /api/admin/draw:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require("express");
|
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 authMiddleware = require("../middlewares/auth.middleware");
|
||||||
const validate = require("../middlewares/validate.middleware");
|
const validate = require("../middlewares/validate.middleware");
|
||||||
const verifyCaptcha = require("../middlewares/captcha.middleware");
|
const verifyCaptcha = require("../middlewares/captcha.middleware");
|
||||||
@@ -113,16 +113,36 @@ router.get("/verify-email/:token",verifyEmail);
|
|||||||
* type: string
|
* type: string
|
||||||
* format: email
|
* format: email
|
||||||
* example: jean.dupont@gmail.com
|
* example: jean.dupont@gmail.com
|
||||||
|
* captchaToken:
|
||||||
|
* type: string
|
||||||
|
* description: Jeton Cloudflare Turnstile (exigé si TURNSTILE_SECRET_KEY est configurée)
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.
|
* description: Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.
|
||||||
* 400:
|
* 400:
|
||||||
* description: Email requis.
|
* description: Email requis, ou vérification anti-robot manquante.
|
||||||
* 500:
|
* 500:
|
||||||
* description: Erreur serveur.
|
* 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
|
* @swagger
|
||||||
* /api/auth/reset-password/{token}:
|
* /api/auth/reset-password/{token}:
|
||||||
@@ -162,6 +182,37 @@ router.post("/reset-password/:token", resetPassword);
|
|||||||
|
|
||||||
router.post("/verify-code", verifyCode);
|
router.post("/verify-code", verifyCode);
|
||||||
router.patch("/change-password", authMiddleware, changePassword);
|
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);
|
router.delete("/account", authMiddleware, deleteAccount);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require("express");
|
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 authMiddleware = require("../middlewares/auth.middleware");
|
||||||
const roleMiddleware = require("../middlewares/role.middleware");
|
const roleMiddleware = require("../middlewares/role.middleware");
|
||||||
const validate = require("../middlewares/validate.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);
|
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;
|
module.exports = router;
|
||||||
@@ -11,6 +11,11 @@ const PHASES = {
|
|||||||
CLOSED: "CLOSED",
|
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 GAME_DURATION_DAYS = 30;
|
||||||
const CLAIM_EXTENSION_DAYS = 30;
|
const CLAIM_EXTENSION_DAYS = 30;
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
@@ -95,6 +100,7 @@ module.exports = {
|
|||||||
effectiveClaimStatus,
|
effectiveClaimStatus,
|
||||||
participationClosedMessage,
|
participationClosedMessage,
|
||||||
PHASES,
|
PHASES,
|
||||||
|
DEFAULT_START_DATE,
|
||||||
GAME_DURATION_DAYS,
|
GAME_DURATION_DAYS,
|
||||||
CLAIM_EXTENSION_DAYS,
|
CLAIM_EXTENSION_DAYS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
const { z } = require("zod");
|
const { z } = require("zod");
|
||||||
|
const { emailSchema } = require("./email.validation");
|
||||||
|
|
||||||
const createEmployeeSchema = z.object({
|
const createEmployeeSchema = z.object({
|
||||||
firstname: z.string().min(2),
|
firstname: z.string().min(2),
|
||||||
lastname: z.string().min(2),
|
lastname: z.string().min(2),
|
||||||
email: z.string().email(),
|
// Pas de mot de passe : il est généré côté serveur (voir createEmployee).
|
||||||
password: z.string().min(8),
|
email: emailSchema,
|
||||||
birthdate: z.string().optional(),
|
birthdate: z.string().optional(),
|
||||||
gender: z.string().optional(),
|
gender: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
const { z } = require("zod");
|
const { z } = require("zod");
|
||||||
|
const { passwordSchema } = require("./password.validation");
|
||||||
|
const { emailSchema } = require("./email.validation");
|
||||||
|
|
||||||
const registerSchema = z.object({
|
const registerSchema = z
|
||||||
firstname: z.string().min(2, "Le prénom doit contenir au moins 2 caractères"),
|
.object({
|
||||||
lastname: z.string().min(2, "Le nom doit contenir au moins 2 caractères"),
|
firstname: z.string().min(2, "Le prénom doit contenir au moins 2 caractères"),
|
||||||
email: z.string().email("Email invalide"),
|
lastname: z.string().min(2, "Le nom doit contenir au moins 2 caractères"),
|
||||||
password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"),
|
email: emailSchema,
|
||||||
birthdate: z.string().optional(),
|
password: passwordSchema,
|
||||||
gender: z.string().optional(),
|
// Vérifiée aussi côté serveur : le contrôle du navigateur ne protège pas
|
||||||
marketingOptIn: z.boolean().optional(),
|
// 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({
|
const loginSchema = z.object({
|
||||||
email: z.string().email("Email invalide"),
|
email: emailSchema,
|
||||||
password: z.string().min(1, "Mot de passe requis"),
|
password: z.string().min(1, "Mot de passe requis"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
11
backend/src/validations/email.validation.js
Normal file
11
backend/src/validations/email.validation.js
Normal file
@@ -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 };
|
||||||
21
backend/src/validations/password.validation.js
Normal file
21
backend/src/validations/password.validation.js
Normal file
@@ -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 };
|
||||||
144
backend/tests/adminDeliveries.test.js
Normal file
144
backend/tests/adminDeliveries.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,13 +21,31 @@ const testEmail = "e2e-flow-test@test.com";
|
|||||||
const ticketCode = "ZZE2ETEST1";
|
const ticketCode = "ZZE2ETEST1";
|
||||||
let token;
|
let token;
|
||||||
let createdTicketId;
|
let createdTicketId;
|
||||||
|
let settingsBackup;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await prisma.user.deleteMany({ where: { email: testEmail } });
|
await prisma.user.deleteMany({ where: { email: testEmail } });
|
||||||
await prisma.ticket.deleteMany({ where: { code: ticketCode } });
|
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 () => {
|
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 } });
|
const user = await prisma.user.findUnique({ where: { email: testEmail } });
|
||||||
if (user) {
|
if (user) {
|
||||||
await prisma.claim.deleteMany({ where: { participation: { userId: user.id } } });
|
await prisma.claim.deleteMany({ where: { participation: { userId: user.id } } });
|
||||||
@@ -48,7 +66,8 @@ describe("THE TIP TOP - FULL E2E FLOW", () => {
|
|||||||
firstname: "E2E",
|
firstname: "E2E",
|
||||||
lastname: "Test",
|
lastname: "Test",
|
||||||
email: testEmail,
|
email: testEmail,
|
||||||
password: "Password123",
|
password: "Password123!",
|
||||||
|
confirmPassword: "Password123!",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(201);
|
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({
|
const res = await request(app).post("/api/auth/login").send({
|
||||||
email: testEmail,
|
email: testEmail,
|
||||||
password: "Password123",
|
password: "Password123!",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
|
|||||||
166
backend/tests/employeeOnboarding.test.js
Normal file
166
backend/tests/employeeOnboarding.test.js
Normal file
@@ -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[^>]*>([^<]+)<\/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);
|
||||||
|
});
|
||||||
|
});
|
||||||
167
backend/tests/forgotPassword.test.js
Normal file
167
backend/tests/forgotPassword.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -87,6 +87,11 @@ describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => {
|
|||||||
log("phase :", vue.body.gameWindow.phase, "| participations ouvertes :", vue.body.gameWindow.isParticipationOpen);
|
log("phase :", vue.body.gameWindow.phase, "| participations ouvertes :", vue.body.gameWindow.isParticipationOpen);
|
||||||
expect(vue.body.gameWindow.phase).toBe("NOT_CONFIGURED");
|
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] });
|
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);
|
log("un client tente de jouer ->", essai.status, essai.body.message);
|
||||||
expect(essai.status).toBe(410);
|
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);
|
log("PUT game-settings ->", r.status, "| phase :", r.body.gameWindow.phase);
|
||||||
expect(r.body.gameWindow.phase).toBe("SCHEDULED");
|
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] });
|
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("client joue avant ouverture ->", anticipe.status);
|
||||||
log("message :", JSON.stringify(anticipe.body.message));
|
log("message :", JSON.stringify(anticipe.body.message));
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ decrire("Inscription Google et definition du mot de passe", () => {
|
|||||||
test("une confirmation qui ne correspond pas est refusee", async () => {
|
test("une confirmation qui ne correspond pas est refusee", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post("/api/auth/reset-password/" + ctxToken)
|
.post("/api/auth/reset-password/" + ctxToken)
|
||||||
.send({ password: "MonMotDePasse1", confirmPassword: "AutreMotDePasse2" });
|
.send({ password: "MonMotDePasse1!", confirmPassword: "AutreMotDePasse2!" });
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(res.body.message).toMatch(/ne correspondent pas/);
|
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/);
|
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 () => {
|
test("le mot de passe defini permet ensuite de se connecter et de le changer", async () => {
|
||||||
const ok = await request(app)
|
const ok = await request(app)
|
||||||
.post("/api/auth/reset-password/" + ctxToken)
|
.post("/api/auth/reset-password/" + ctxToken)
|
||||||
.send({ password: "MonMotDePasse1", confirmPassword: "MonMotDePasse1" })
|
.send({ password: "MonMotDePasse1!", confirmPassword: "MonMotDePasse1!" })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(ok.body.message).toMatch(/réinitialisé/);
|
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();
|
expect(login.body.token).toBeDefined();
|
||||||
|
|
||||||
// Le jeton a bien ete consomme.
|
// Le jeton a bien ete consomme.
|
||||||
@@ -125,8 +134,16 @@ decrire("Inscription Google et definition du mot de passe", () => {
|
|||||||
const change = await request(app)
|
const change = await request(app)
|
||||||
.patch("/api/auth/change-password")
|
.patch("/api/auth/change-password")
|
||||||
.set("Authorization", "Bearer " + login.body.token)
|
.set("Authorization", "Bearer " + login.body.token)
|
||||||
.send({ currentPassword: "MonMotDePasse1", newPassword: "EncoreUnAutre2" });
|
.send({ currentPassword: "MonMotDePasse1!", newPassword: "EncoreUnAutre2!" });
|
||||||
expect(change.status).toBe(200);
|
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 () => {
|
test("une seconde connexion Google ne renvoie pas de nouveau lien", async () => {
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ const payload = {
|
|||||||
firstname: "Camille",
|
firstname: "Camille",
|
||||||
lastname: "Martin",
|
lastname: "Martin",
|
||||||
email: testEmail,
|
email: testEmail,
|
||||||
password: "Password123",
|
password: "Password123!",
|
||||||
|
confirmPassword: "Password123!",
|
||||||
birthdate: "1990-05-14",
|
birthdate: "1990-05-14",
|
||||||
gender: "Femme",
|
gender: "Femme",
|
||||||
};
|
};
|
||||||
|
|||||||
62
backend/tests/registerPassword.test.js
Normal file
62
backend/tests/registerPassword.test.js
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,6 +7,8 @@ import Connexion from './pages/Connexion'
|
|||||||
import Inscription from './pages/Inscription'
|
import Inscription from './pages/Inscription'
|
||||||
import AuthCallback from './pages/AuthCallback'
|
import AuthCallback from './pages/AuthCallback'
|
||||||
import DefinirMotDePasse from './pages/DefinirMotDePasse'
|
import DefinirMotDePasse from './pages/DefinirMotDePasse'
|
||||||
|
import MotDePasseOublie from './pages/MotDePasseOublie'
|
||||||
|
import NouveauMotDePasse from './pages/NouveauMotDePasse'
|
||||||
import Accueil from './pages/Accueil'
|
import Accueil from './pages/Accueil'
|
||||||
import NotreConcept from './pages/NotreConcept'
|
import NotreConcept from './pages/NotreConcept'
|
||||||
import ReglesJeu from './pages/ReglesJeu'
|
import ReglesJeu from './pages/ReglesJeu'
|
||||||
@@ -33,7 +35,9 @@ export default function App() {
|
|||||||
<Route path="/connexion" element={<Connexion />} />
|
<Route path="/connexion" element={<Connexion />} />
|
||||||
<Route path="/inscription" element={<Inscription />} />
|
<Route path="/inscription" element={<Inscription />} />
|
||||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
|
<Route path="/mot-de-passe-oublie" element={<MotDePasseOublie />} />
|
||||||
<Route path="/definir-mot-de-passe/:token" element={<DefinirMotDePasse />} />
|
<Route path="/definir-mot-de-passe/:token" element={<DefinirMotDePasse />} />
|
||||||
|
<Route path="/nouveau-mot-de-passe" element={<NouveauMotDePasse />} />
|
||||||
|
|
||||||
{/* Pages publiques */}
|
{/* Pages publiques */}
|
||||||
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />
|
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />
|
||||||
|
|||||||
21
frontend/src/components/PasswordRules.jsx
Normal file
21
frontend/src/components/PasswordRules.jsx
Normal file
@@ -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 (
|
||||||
|
<ul id={id} className="mt-2 space-y-1">
|
||||||
|
{PASSWORD_RULES.map((rule) => {
|
||||||
|
const ok = rule.test(password)
|
||||||
|
return (
|
||||||
|
<li key={rule.id} data-rule={rule.id} data-valid={ok}
|
||||||
|
className={`flex items-center gap-2 text-xs ${ok ? 'text-green-700' : 'text-gray-500'}`}>
|
||||||
|
<span aria-hidden="true">{ok ? '✓' : '○'}</span>
|
||||||
|
{rule.label}
|
||||||
|
<span className="sr-only">{ok ? ' : respecté' : ' : non respecté'}</span>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Navigate } from 'react-router-dom'
|
import { Navigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { homeFor, INITIAL_PASSWORD_PATH } from '../config/roles'
|
||||||
|
|
||||||
export default function ProtectedRoute({ children, roles }) {
|
export default function ProtectedRoute({ children, roles }) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
@@ -8,9 +9,14 @@ export default function ProtectedRoute({ children, roles }) {
|
|||||||
return <Navigate to="/connexion" replace />
|
return <Navigate to="/connexion" replace />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 <Navigate to={INITIAL_PASSWORD_PATH} replace />
|
||||||
|
}
|
||||||
|
|
||||||
if (roles && !roles.includes(user.role)) {
|
if (roles && !roles.includes(user.role)) {
|
||||||
const redirects = { ADMIN: '/admin', EMPLOYEE: '/employe', CLIENT: '/mes-gains' }
|
return <Navigate to={homeFor(user)} replace />
|
||||||
return <Navigate to={redirects[user.role] || '/connexion'} replace />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return children
|
return children
|
||||||
|
|||||||
288
frontend/src/components/admin/EmployeeDeliveries.jsx
Normal file
288
frontend/src/components/admin/EmployeeDeliveries.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Employés et remises des lots</h2>
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500">
|
||||||
|
Retrouvez quel employé a remis un lot et à quelle date, par numéro de ticket ou par employé.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Recherche par numéro de ticket ── */}
|
||||||
|
<div>
|
||||||
|
<form onSubmit={handleTicketSearch} className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="flex-1 min-w-[200px]">
|
||||||
|
<label htmlFor="delivery-ticket-code" className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
Numéro du ticket
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="delivery-ticket-code"
|
||||||
|
value={ticketCode}
|
||||||
|
onChange={(e) => setTicketCode(e.target.value)}
|
||||||
|
required
|
||||||
|
placeholder="Ex. A1B2C3D4E5"
|
||||||
|
className={`${INPUT_CLASS} uppercase placeholder:normal-case`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={ticketLoading || !ticketCode.trim()} className={BUTTON_CLASS}>
|
||||||
|
{ticketLoading ? 'Recherche…' : 'Rechercher'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{ticketError && <p className="text-red-500 text-xs sm:text-sm mt-3">{ticketError}</p>}
|
||||||
|
|
||||||
|
{ticketResult && (
|
||||||
|
<div className="mt-4 p-4 rounded-lg border border-gray-200 bg-gray-50 text-sm space-y-1.5" data-testid="ticket-result">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<p className="font-mono font-semibold text-gray-800">{ticketResult.ticketCode}</p>
|
||||||
|
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${STATUS_BADGES[ticketResult.status]?.className}`}>
|
||||||
|
{STATUS_BADGES[ticketResult.status]?.label ?? ticketResult.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600">Lot : <span className="font-medium text-gray-800">{ticketResult.lot.name}</span></p>
|
||||||
|
{ticketResult.client && (
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Gagné par : <span className="font-medium text-gray-800">{fullName(ticketResult.client)}</span>{' '}
|
||||||
|
<span className="text-gray-500">({ticketResult.client.email})</span>
|
||||||
|
{ticketResult.playedAt && <> le {formatDateTime(ticketResult.playedAt)}</>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{ticketResult.deliveredBy ? (
|
||||||
|
<p className="text-gray-600">
|
||||||
|
Remis par :{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEmployee(ticketResult.deliveredBy)}
|
||||||
|
className="font-semibold text-[#006B6B] hover:underline"
|
||||||
|
>
|
||||||
|
{fullName(ticketResult.deliveredBy)}
|
||||||
|
</button>{' '}
|
||||||
|
le <span className="font-medium text-gray-800">{formatDateTime(ticketResult.deliveredAt)}</span>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
ticketResult.status !== 'NOT_PLAYED' && (
|
||||||
|
<p className="text-gray-500">Ce lot n'a pas encore été remis.</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Liste des employés ── */}
|
||||||
|
<div className="pt-6 border-t border-gray-100">
|
||||||
|
<div className="flex items-end justify-between flex-wrap gap-3 mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800">
|
||||||
|
Comptes employés
|
||||||
|
{employees && <span className="text-gray-500 font-normal"> - {employees.length}</span>}
|
||||||
|
</h3>
|
||||||
|
<div className="w-full sm:w-64">
|
||||||
|
<label htmlFor="employee-filter" className="sr-only">Rechercher un employé</label>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
id="employee-filter"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
placeholder="Rechercher un employé…"
|
||||||
|
className={INPUT_CLASS}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{employeesError && <p className="text-red-500 text-sm">{employeesError}</p>}
|
||||||
|
{!employees && !employeesError && <p className="text-gray-500 text-sm">Chargement…</p>}
|
||||||
|
|
||||||
|
{employees && (
|
||||||
|
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||||
|
<table className="w-full text-sm min-w-[520px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Nom</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Email</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Créé le</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0 text-center">Lots remis</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{visibleEmployees.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="py-6 text-center text-gray-500 text-sm">
|
||||||
|
{employees.length === 0 ? "Aucun compte employé pour l'instant" : 'Aucun employé ne correspond à la recherche'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{visibleEmployees.map((employee) => (
|
||||||
|
<tr key={employee.id} className={`border-b last:border-0 ${selected?.id === employee.id ? 'bg-[#006B6B]/5' : ''}`}>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-xs sm:text-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEmployee(employee)}
|
||||||
|
className="font-semibold text-[#006B6B] hover:underline text-left"
|
||||||
|
>
|
||||||
|
{fullName(employee)}
|
||||||
|
</button>
|
||||||
|
{employee.mustChangePassword && (
|
||||||
|
<span className="block text-[11px] text-amber-700">Première connexion en attente</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">{employee.email}</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">
|
||||||
|
{new Date(employee.createdAt).toLocaleDateString('fr-FR')}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-center font-semibold text-gray-800">{employee.deliveredCount}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Lots remis par l'employé sélectionné ── */}
|
||||||
|
{selected && (
|
||||||
|
<div ref={detailRef} className="pt-6 border-t border-gray-100">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2 mb-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-800">
|
||||||
|
Lots remis par {fullName(selected)}
|
||||||
|
{deliveries && <span className="text-gray-500 font-normal"> — {deliveries.length}</span>}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelected(null)}
|
||||||
|
className="border border-gray-200 text-gray-600 hover:bg-gray-50 font-semibold px-3 py-1.5 rounded-lg transition-colors text-xs"
|
||||||
|
>
|
||||||
|
Fermer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{deliveriesLoading && <p className="text-gray-500 text-sm">Chargement…</p>}
|
||||||
|
{deliveriesError && <p className="text-red-500 text-sm">{deliveriesError}</p>}
|
||||||
|
|
||||||
|
{deliveries && (
|
||||||
|
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||||
|
<table className="w-full text-sm min-w-[560px]">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Ticket</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Lot</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0">Client</th>
|
||||||
|
<th className="pb-2 px-4 sm:px-0 text-right">Remis le</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{deliveries.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="py-6 text-center text-gray-500 text-sm">
|
||||||
|
Cet employé n'a encore remis aucun lot
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{deliveries.map((delivery) => (
|
||||||
|
<tr key={delivery.claimId} className="border-b last:border-0">
|
||||||
|
<td className="py-3 px-4 sm:px-0 font-mono text-xs text-gray-800">{delivery.ticketCode}</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-xs sm:text-sm text-gray-800">{delivery.lot.name}</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-xs text-gray-500">
|
||||||
|
{fullName(delivery.client)}
|
||||||
|
<span className="block">{delivery.client.email}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-4 sm:px-0 text-right text-xs text-gray-500 whitespace-nowrap">
|
||||||
|
{formatDateTime(delivery.deliveredAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
10
frontend/src/config/roles.js
Normal file
10
frontend/src/config/roles.js
Normal file
@@ -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'
|
||||||
|
}
|
||||||
26
frontend/src/hooks/useGameDates.js
Normal file
26
frontend/src/hooks/useGameDates.js
Normal file
@@ -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])
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import usePageMeta from '../hooks/usePageMeta'
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
import useGameDates from '../hooks/useGameDates'
|
||||||
|
import { formatDay, formatRange } from '../utils/gameDates'
|
||||||
import bannerImg from '../assets/img/banner-accueil.webp'
|
import bannerImg from '../assets/img/banner-accueil.webp'
|
||||||
|
|
||||||
const gains = [
|
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 ?',
|
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 ?',
|
question: 'Quels sont les lots à gagner ?',
|
||||||
@@ -62,7 +65,7 @@ const faqs = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Jusqu\'à quand puis-je jouer ?',
|
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 ?',
|
question: 'Que faire si mon code n\'est pas reconnu ?',
|
||||||
@@ -70,11 +73,11 @@ const faqs = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
question: 'Où retirer mon lot ?',
|
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',
|
'@context': 'https://schema.org',
|
||||||
'@type': 'FAQPage',
|
'@type': 'FAQPage',
|
||||||
mainEntity: faqs.map((faq) => ({
|
mainEntity: faqs.map((faq) => ({
|
||||||
@@ -85,12 +88,18 @@ const faqJsonLd = {
|
|||||||
text: faq.plainAnswer || faq.answer,
|
text: faq.plainAnswer || faq.answer,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
}
|
})
|
||||||
|
|
||||||
export default function Accueil() {
|
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({
|
usePageMeta({
|
||||||
title: 'Jeu-concours 100 % Gagnant du 2 au 31 octobre | Thé Tip Top',
|
title: `Jeu-concours 100 % Gagnant du ${formatRange(gameDates.start, gameDates.lastPlayDay, { withYear: false })} | 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.",
|
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), [])
|
const teaParticles = useMemo(() => createTeaParticles(30), [])
|
||||||
@@ -130,7 +139,7 @@ export default function Accueil() {
|
|||||||
|
|
||||||
<div className="static lg:absolute lg:left-0 lg:top-10 max-w-full lg:max-w-md mb-6 lg:mb-0">
|
<div className="static lg:absolute lg:left-0 lg:top-10 max-w-full lg:max-w-md mb-6 lg:mb-0">
|
||||||
<p className="inline-block text-sm sm:text-base font-bold uppercase tracking-widest text-amber-300 border border-white/70 rounded-lg px-3.5 py-2 mb-3">
|
<p className="inline-block text-sm sm:text-base font-bold uppercase tracking-widest text-amber-300 border border-white/70 rounded-lg px-3.5 py-2 mb-3">
|
||||||
Du 02 au 31 octobre 2026
|
Du {formatRange(gameDates.start, gameDates.lastPlayDay, { padDay: true })}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="text-3xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-[1.05]">
|
<h1 className="text-3xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-[1.05]">
|
||||||
Jeu-concours{' '}
|
Jeu-concours{' '}
|
||||||
@@ -142,13 +151,13 @@ export default function Accueil() {
|
|||||||
<div className="relative aspect-[16/10] sm:aspect-[21/9] overflow-hidden lg:hidden">
|
<div className="relative aspect-[16/10] sm:aspect-[21/9] overflow-hidden lg:hidden">
|
||||||
<img
|
<img
|
||||||
src={bannerImg}
|
src={bannerImg}
|
||||||
alt="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."
|
alt={bannerAlt}
|
||||||
className="absolute inset-y-0 right-0 w-[210%] sm:w-[130%] max-w-none h-full object-cover object-right animate-banner-image"
|
className="absolute inset-y-0 right-0 w-[210%] sm:w-[130%] max-w-none h-full object-cover object-right animate-banner-image"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<img
|
<img
|
||||||
src={bannerImg}
|
src={bannerImg}
|
||||||
alt="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."
|
alt={bannerAlt}
|
||||||
width={2048}
|
width={2048}
|
||||||
height={1152}
|
height={1152}
|
||||||
className="hidden lg:block lg:absolute lg:right-[-2%] lg:top-1/2 lg:-translate-y-1/2 lg:h-[145%] xl:h-[160%] lg:w-auto lg:max-w-none object-contain drop-shadow-2xl animate-banner-image"
|
className="hidden lg:block lg:absolute lg:right-[-2%] lg:top-1/2 lg:-translate-y-1/2 lg:h-[145%] xl:h-[160%] lg:w-auto lg:max-w-none object-contain drop-shadow-2xl animate-banner-image"
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi, createEmployeeApi } from '../services/admin'
|
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi, createEmployeeApi } from '../services/admin'
|
||||||
|
import EmployeeDeliveries from '../components/admin/EmployeeDeliveries'
|
||||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||||
|
import { formatDay, formatRange, gameDatesFromStart } from '../utils/gameDates'
|
||||||
|
|
||||||
function StatCard({ label, value, sub }) {
|
function StatCard({ label, value, sub }) {
|
||||||
return (
|
return (
|
||||||
@@ -38,6 +40,13 @@ const PHASE_TITLES = {
|
|||||||
CLOSED: 'Jeu-concours terminé',
|
CLOSED: 'Jeu-concours terminé',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Valeur attendue par <input type="datetime-local"> : 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) {
|
function formatDate(dateStr) {
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||||
day: '2-digit', month: 'long', year: 'numeric',
|
day: '2-digit', month: 'long', year: 'numeric',
|
||||||
@@ -68,15 +77,19 @@ export default function Admin() {
|
|||||||
const [emailingError, setEmailingError] = useState('')
|
const [emailingError, setEmailingError] = useState('')
|
||||||
|
|
||||||
// Création d'un compte employé
|
// 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 [employeeSaving, setEmployeeSaving] = useState(false)
|
||||||
const [employeeError, setEmployeeError] = useState('')
|
const [employeeError, setEmployeeError] = useState('')
|
||||||
const [employeeOk, setEmployeeOk] = useState('')
|
const [employeeOk, setEmployeeOk] = useState('')
|
||||||
|
// Incrémenté à chaque création pour recharger la liste des employés.
|
||||||
|
const [employeesVersion, setEmployeesVersion] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
|
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
|
||||||
.then(([s, u, w]) => {
|
.then(([s, u, w]) => {
|
||||||
setStats(s)
|
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)
|
setUsers(u.users)
|
||||||
setWinner(w)
|
setWinner(w)
|
||||||
})
|
})
|
||||||
@@ -96,8 +109,8 @@ export default function Admin() {
|
|||||||
// convertit en ISO pour que le serveur reçoive un instant sans ambiguïté.
|
// convertit en ISO pour que le serveur reçoive un instant sans ambiguïté.
|
||||||
const data = await setGameSettingsApi(new Date(startDateInput).toISOString())
|
const data = await setGameSettingsApi(new Date(startDateInput).toISOString())
|
||||||
setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev))
|
setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev))
|
||||||
setSettingsOk(data.message)
|
setSettingsOk(`${data.message} La page d'accueil affiche désormais ces dates.`)
|
||||||
setStartDateInput('')
|
setStartDateInput(toDateTimeLocalValue(data.gameWindow.startDate))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!handleAuthError(err)) setSettingsError(err.message)
|
if (!handleAuthError(err)) setSettingsError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -157,8 +170,9 @@ export default function Admin() {
|
|||||||
setEmployeeSaving(true)
|
setEmployeeSaving(true)
|
||||||
try {
|
try {
|
||||||
const data = await createEmployeeApi(employeeForm)
|
const data = await createEmployeeApi(employeeForm)
|
||||||
setEmployeeOk(`Compte employé créé pour ${data.employee.firstname} ${data.employee.lastname} (${data.employee.email}).`)
|
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: '', password: '' })
|
setEmployeeForm({ firstname: '', lastname: '', email: '' })
|
||||||
|
setEmployeesVersion((v) => v + 1)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!handleAuthError(err)) setEmployeeError(err.message)
|
if (!handleAuthError(err)) setEmployeeError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -180,6 +194,15 @@ export default function Admin() {
|
|||||||
|
|
||||||
const winnerUser = winner?.winner?.user
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
|
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
|
||||||
<div className="max-w-5xl mx-auto space-y-6">
|
<div className="max-w-5xl mx-auto space-y-6">
|
||||||
@@ -236,6 +259,17 @@ export default function Admin() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{startPreview && (
|
||||||
|
<p className="text-xs text-gray-700 mt-3" data-testid="homepage-dates-preview">
|
||||||
|
Affiché sur la page d'accueil :{' '}
|
||||||
|
<span className="font-semibold">
|
||||||
|
Du {formatRange(startPreview.start, startPreview.lastPlayDay, { padDay: true })}
|
||||||
|
</span>
|
||||||
|
, retrait des lots jusqu'au <span className="font-semibold">{formatDay(startPreview.lastClaimDay)}</span>
|
||||||
|
{startPreviewUnsaved && <span className="text-amber-700"> (non enregistré)</span>}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.
|
Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.
|
||||||
</p>
|
</p>
|
||||||
@@ -392,6 +426,7 @@ export default function Admin() {
|
|||||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Créer un compte employé</h2>
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Créer un compte employé</h2>
|
||||||
<p className="text-xs sm:text-sm text-gray-500 mb-4">
|
<p className="text-xs sm:text-sm text-gray-500 mb-4">
|
||||||
Donne accès à l'espace employé (remise des lots en boutique). L'admin ne gère pas les remises lui-même.
|
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.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleCreateEmployee} className="grid sm:grid-cols-2 gap-3">
|
<form onSubmit={handleCreateEmployee} className="grid sm:grid-cols-2 gap-3">
|
||||||
@@ -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]"
|
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]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label htmlFor="employee-password" className="block text-xs font-medium text-gray-700 mb-1">Mot de passe</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="employee-password"
|
|
||||||
value={employeeForm.password}
|
|
||||||
onChange={(e) => 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]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sm:col-span-2 flex items-center gap-3 pt-1">
|
<div className="sm:col-span-2 flex items-center gap-3 pt-1">
|
||||||
<button
|
<button
|
||||||
@@ -456,6 +478,9 @@ export default function Admin() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ── Employés et remises des lots ── */}
|
||||||
|
<EmployeeDeliveries refreshKey={employeesVersion} onAuthError={handleAuthError} />
|
||||||
|
|
||||||
{/* ── Tirage au sort ── */}
|
{/* ── Tirage au sort ── */}
|
||||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Grand tirage au sort</h2>
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Grand tirage au sort</h2>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
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
|
// 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.
|
// avec le JWT et le profil encodés dans l'URL.
|
||||||
@@ -40,10 +41,7 @@ export default function AuthCallback() {
|
|||||||
handled.current = true
|
handled.current = true
|
||||||
|
|
||||||
login(user, token)
|
login(user, token)
|
||||||
|
navigate(homeFor(user), { replace: true })
|
||||||
if (user.role === 'ADMIN') navigate('/admin', { replace: true })
|
|
||||||
else if (user.role === 'EMPLOYEE') navigate('/employe', { replace: true })
|
|
||||||
else navigate('/mes-gains', { replace: true })
|
|
||||||
}, [user, token, login, navigate])
|
}, [user, token, login, navigate])
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext'
|
|||||||
import usePageMeta from '../hooks/usePageMeta'
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
import Captcha from '../components/Captcha'
|
import Captcha from '../components/Captcha'
|
||||||
import { CAPTCHA_ENABLED } from '../config/captcha'
|
import { CAPTCHA_ENABLED } from '../config/captcha'
|
||||||
|
import { homeFor } from '../config/roles'
|
||||||
|
|
||||||
export default function Connexion() {
|
export default function Connexion() {
|
||||||
usePageMeta({
|
usePageMeta({
|
||||||
@@ -39,9 +40,9 @@ export default function Connexion() {
|
|||||||
try {
|
try {
|
||||||
const { token, user } = await loginApi({ ...form, captchaToken })
|
const { token, user } = await loginApi({ ...form, captchaToken })
|
||||||
login(user, token)
|
login(user, token)
|
||||||
if (user.role === 'ADMIN') navigate('/admin')
|
// Un employé qui se connecte avec son mot de passe provisoire est
|
||||||
else if (user.role === 'EMPLOYEE') navigate('/employe')
|
// envoyé directement sur le choix de son nouveau mot de passe.
|
||||||
else navigate('/mes-gains')
|
navigate(homeFor(user))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
// Le jeton a été consommé par le backend : on en redemande un.
|
// Le jeton a été consommé par le backend : on en redemande un.
|
||||||
@@ -119,9 +120,13 @@ export default function Connexion() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right mt-1">
|
<div className="text-right mt-1">
|
||||||
<button type="button" className="text-xs text-gray-500 hover:text-[#006B6B] hover:underline">
|
<Link
|
||||||
|
to="/mot-de-passe-oublie"
|
||||||
|
state={{ email: form.email }}
|
||||||
|
className="text-xs text-gray-500 hover:text-[#006B6B] hover:underline"
|
||||||
|
>
|
||||||
Mot de passe oublié ?
|
Mot de passe oublié ?
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { resetPasswordApi } from '../services/auth'
|
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 :
|
// 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,
|
// la définition initiale du mot de passe après une inscription Google,
|
||||||
@@ -21,8 +23,8 @@ export default function DefinirMotDePasse() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
if (form.password.length < 8) {
|
if (!isPasswordValid(form.password)) {
|
||||||
setError('Le mot de passe doit contenir au moins 8 caractères.')
|
setError(PASSWORD_RULES_ERROR)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (form.password !== form.confirmPassword) {
|
if (form.password !== form.confirmPassword) {
|
||||||
@@ -58,15 +60,22 @@ export default function DefinirMotDePasse() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Mot de passe</h1>
|
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Nouveau mot de passe</h1>
|
||||||
|
{/* Texte neutre : la page sert au mot de passe oublié comme au
|
||||||
|
premier mot de passe d'un compte Google. */}
|
||||||
<p className="text-gray-500 mt-1 text-sm">
|
<p className="text-gray-500 mt-1 text-sm">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm" role="alert">
|
||||||
{error}
|
{error}
|
||||||
|
{/invalide ou expiré/i.test(error) && (
|
||||||
|
<Link to="/mot-de-passe-oublie" className="block mt-2 font-medium text-[#006B6B] hover:underline">
|
||||||
|
Demander un nouveau lien
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -82,10 +91,12 @@ export default function DefinirMotDePasse() {
|
|||||||
value={form.password}
|
value={form.password}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
minLength={8}
|
autoComplete="new-password"
|
||||||
placeholder="8 caractères minimum"
|
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]"
|
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]"
|
||||||
/>
|
/>
|
||||||
|
<PasswordRules id="password-rules" password={form.password} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useAuth } from '../context/AuthContext'
|
|||||||
import usePageMeta from '../hooks/usePageMeta'
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
import Captcha from '../components/Captcha'
|
import Captcha from '../components/Captcha'
|
||||||
import { CAPTCHA_ENABLED } from '../config/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.
|
// Une naissance ne peut pas être dans le futur : borne haute du sélecteur.
|
||||||
const AUJOURD_HUI = new Date().toISOString().split('T')[0]
|
const AUJOURD_HUI = new Date().toISOString().split('T')[0]
|
||||||
@@ -42,6 +44,7 @@ export default function Inscription() {
|
|||||||
lastName: '',
|
lastName: '',
|
||||||
firstName: '',
|
firstName: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
birthdate: '',
|
birthdate: '',
|
||||||
gender: '',
|
gender: '',
|
||||||
})
|
})
|
||||||
@@ -63,6 +66,9 @@ export default function Inscription() {
|
|||||||
|
|
||||||
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
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.
|
// Même route que la connexion : le provider crée le compte à la première venue.
|
||||||
const handleOAuthSignup = (provider) => {
|
const handleOAuthSignup = (provider) => {
|
||||||
const apiUrl = import.meta.env.VITE_API_URL || ''
|
const apiUrl = import.meta.env.VITE_API_URL || ''
|
||||||
@@ -79,6 +85,16 @@ export default function Inscription() {
|
|||||||
return
|
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)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await registerApi({
|
await registerApi({
|
||||||
@@ -86,6 +102,7 @@ export default function Inscription() {
|
|||||||
firstname: form.firstName,
|
firstname: form.firstName,
|
||||||
lastname: form.lastName,
|
lastname: form.lastName,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
|
confirmPassword: form.confirmPassword,
|
||||||
birthdate: form.birthdate,
|
birthdate: form.birthdate,
|
||||||
gender: form.gender,
|
gender: form.gender,
|
||||||
marketingOptIn,
|
marketingOptIn,
|
||||||
@@ -256,6 +273,7 @@ export default function Inscription() {
|
|||||||
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
|
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input type={showPassword ? 'text' : 'password'} id="reg-password" name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
|
<input type={showPassword ? 'text' : 'password'} id="reg-password" name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
|
||||||
|
autoComplete="new-password" aria-describedby="reg-password-rules"
|
||||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||||
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600" aria-label="Afficher/masquer">
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600" aria-label="Afficher/masquer">
|
||||||
@@ -264,6 +282,17 @@ export default function Inscription() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<PasswordRules id="reg-password-rules" password={form.password} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reg-confirm-password" className="block text-sm font-medium text-gray-800 mb-1">Confirmer le mot de passe <span className="text-gray-800">*</span></label>
|
||||||
|
<input type={showPassword ? 'text' : 'password'} id="reg-confirm-password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required placeholder="Saisissez-le à nouveau"
|
||||||
|
autoComplete="new-password" aria-invalid={passwordMismatch} aria-describedby={passwordMismatch ? 'reg-confirm-password-error' : undefined}
|
||||||
|
className={`w-full border rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 ${passwordMismatch ? 'border-red-300 focus:ring-red-400 focus:border-red-400' : 'border-gray-200 focus:ring-[#006B6B] focus:border-[#006B6B]'}`} />
|
||||||
|
{passwordMismatch && (
|
||||||
|
<p id="reg-confirm-password-error" className="text-xs text-red-600 mt-1">Les deux mots de passe ne correspondent pas.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
|||||||
117
frontend/src/pages/MotDePasseOublie.jsx
Normal file
117
frontend/src/pages/MotDePasseOublie.jsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { forgotPasswordApi } from '../services/auth'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
import Captcha from '../components/Captcha'
|
||||||
|
import { CAPTCHA_ENABLED } from '../config/captcha'
|
||||||
|
|
||||||
|
// Demande d'un lien de réinitialisation. La réponse est volontairement la même
|
||||||
|
// que le compte existe ou non, pour ne pas révéler quels emails sont inscrits.
|
||||||
|
export default function MotDePasseOublie() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Mot de passe oublié | Thé Tip Top',
|
||||||
|
description: 'Recevez par email un lien pour choisir un nouveau mot de passe pour votre compte Thé Tip Top.',
|
||||||
|
})
|
||||||
|
|
||||||
|
// L'email déjà saisi sur la page de connexion est repris.
|
||||||
|
const { state } = useLocation()
|
||||||
|
const [email, setEmail] = useState(state?.email ?? '')
|
||||||
|
const [sent, setSent] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [captchaToken, setCaptchaToken] = useState('')
|
||||||
|
const captchaRef = useRef()
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await forgotPasswordApi({ email, captchaToken })
|
||||||
|
setSent(data.message)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
// Le jeton a été consommé par le backend : on en redemande un.
|
||||||
|
captchaRef.current?.reset()
|
||||||
|
setCaptchaToken('')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto text-center">
|
||||||
|
<h1 className="text-3xl font-bold italic font-serif text-[#006B6B] mb-4">Vérifiez vos emails</h1>
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm" role="status">
|
||||||
|
{sent}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Le lien est valable 30 minutes. Pensez à regarder dans vos courriers indésirables.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setSent(''); setCaptchaToken('') }}
|
||||||
|
className="text-sm text-gray-500 hover:text-[#006B6B] hover:underline mb-3"
|
||||||
|
>
|
||||||
|
Je n'ai rien reçu, renvoyer un lien
|
||||||
|
</button>
|
||||||
|
<Link to="/connexion" className="text-sm text-[#006B6B] font-medium hover:underline">
|
||||||
|
Retour à la connexion
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Mot de passe oublié</h1>
|
||||||
|
<p className="text-gray-500 mt-2 text-sm">
|
||||||
|
Saisissez l'adresse email de votre compte : nous vous enverrons un lien pour choisir un nouveau mot de passe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="forgot-email" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
|
Adresse email <span className="text-gray-800">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="forgot-email"
|
||||||
|
name="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="Votre email"
|
||||||
|
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]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || (CAPTCHA_ENABLED && !captchaToken)}
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Envoi…' : 'Recevoir le lien'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-8">
|
||||||
|
<Link to="/connexion" className="text-gray-700 font-medium hover:underline">
|
||||||
|
Retour à la connexion
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
frontend/src/pages/NouveauMotDePasse.jsx
Normal file
139
frontend/src/pages/NouveauMotDePasse.jsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Navigate, useNavigate } from 'react-router-dom'
|
||||||
|
import { setInitialPasswordApi } from '../services/auth'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { homeFor } from '../config/roles'
|
||||||
|
import PasswordRules from '../components/PasswordRules'
|
||||||
|
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
|
||||||
|
|
||||||
|
// Première connexion d'un employé créé par un admin : le mot de passe reçu
|
||||||
|
// par email est provisoire, il doit être remplacé avant tout accès.
|
||||||
|
export default function NouveauMotDePasse() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, login, logout } = useAuth()
|
||||||
|
|
||||||
|
const [form, setForm] = useState({ password: '', confirmPassword: '' })
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
if (!user) return <Navigate to="/connexion" replace />
|
||||||
|
if (!user.mustChangePassword) return <Navigate to={homeFor(user)} replace />
|
||||||
|
|
||||||
|
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
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 setInitialPasswordApi(form)
|
||||||
|
const updatedUser = { ...user, mustChangePassword: false }
|
||||||
|
login(updatedUser, localStorage.getItem('token'))
|
||||||
|
navigate(homeFor(updatedUser), { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 401) {
|
||||||
|
logout()
|
||||||
|
navigate('/connexion', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(err.message)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
navigate('/connexion', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Nouveau mot de passe</h1>
|
||||||
|
<p className="text-gray-500 mt-2 text-sm">
|
||||||
|
Bienvenue {user.firstname} ! Vous vous êtes connecté avec un mot de passe provisoire.
|
||||||
|
Choisissez votre propre mot de passe pour accéder à votre espace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="new-password" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
|
Nouveau mot de passe <span className="text-gray-800">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="new-password"
|
||||||
|
name="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
aria-describedby="new-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]"
|
||||||
|
/>
|
||||||
|
<PasswordRules id="new-password-rules" password={form.password} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirm-new-password" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
|
Confirmer le mot de passe <span className="text-gray-800">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="confirm-new-password"
|
||||||
|
name="confirmPassword"
|
||||||
|
value={form.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="Saisissez-le à nouveau"
|
||||||
|
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]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showPassword}
|
||||||
|
onChange={(e) => setShowPassword(e.target.checked)}
|
||||||
|
className="w-5 h-5 accent-[#006B6B]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500">Afficher les mots de passe</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Enregistrement…' : 'Enregistrer mon mot de passe'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-8">
|
||||||
|
<button type="button" onClick={handleLogout} className="text-gray-700 font-medium hover:underline">
|
||||||
|
Se déconnecter
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import UserNav from '../components/UserNav'
|
import UserNav from '../components/UserNav'
|
||||||
import { changePasswordApi, meApi, forgotPasswordApi } from '../services/auth'
|
import { changePasswordApi, meApi, sendPasswordSetupLinkApi } from '../services/auth'
|
||||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||||
|
import PasswordRules from '../components/PasswordRules'
|
||||||
|
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
|
||||||
|
|
||||||
export default function Profile() {
|
export default function Profile() {
|
||||||
const user = JSON.parse(localStorage.getItem('user') || '{}')
|
const user = JSON.parse(localStorage.getItem('user') || '{}')
|
||||||
@@ -47,7 +49,7 @@ export default function Profile() {
|
|||||||
setLinkError('')
|
setLinkError('')
|
||||||
setLinkSending(true)
|
setLinkSending(true)
|
||||||
try {
|
try {
|
||||||
await forgotPasswordApi(user.email)
|
await sendPasswordSetupLinkApi()
|
||||||
setLinkSent(true)
|
setLinkSent(true)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!handleAuthError(e)) setLinkError(e.message)
|
if (!handleAuthError(e)) setLinkError(e.message)
|
||||||
@@ -67,8 +69,8 @@ export default function Profile() {
|
|||||||
setPwError('Les nouveaux mots de passe ne correspondent pas.')
|
setPwError('Les nouveaux mots de passe ne correspondent pas.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (pwForm.newPassword.length < 8) {
|
if (!isPasswordValid(pwForm.newPassword)) {
|
||||||
setPwError('Le nouveau mot de passe doit faire au moins 8 caractères.')
|
setPwError(PASSWORD_RULES_ERROR)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +195,8 @@ export default function Profile() {
|
|||||||
value={pwForm[name]}
|
value={pwForm[name]}
|
||||||
onChange={handlePwChange}
|
onChange={handlePwChange}
|
||||||
required
|
required
|
||||||
|
autoComplete={name === 'currentPassword' ? 'current-password' : 'new-password'}
|
||||||
|
aria-describedby={name === 'newPassword' ? 'profile-password-rules' : undefined}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-11 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-11 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
/>
|
/>
|
||||||
@@ -205,6 +209,9 @@ export default function Profile() {
|
|||||||
<EyeIcon visible={showPw[key]} />
|
<EyeIcon visible={showPw[key]} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{name === 'newPassword' && (
|
||||||
|
<PasswordRules id="profile-password-rules" password={pwForm.newPassword} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -61,11 +61,34 @@ export async function getWinnerApi() {
|
|||||||
return data
|
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`, {
|
const res = await fetch(`${BASE}/employees`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders(),
|
headers: authHeaders(),
|
||||||
body: JSON.stringify({ firstname, lastname, email, password }),
|
body: JSON.stringify({ firstname, lastname, email }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw apiError(data.message || "Erreur lors de la création de l'employé.", res.status)
|
if (!res.ok) throw apiError(data.message || "Erreur lors de la création de l'employé.", res.status)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export async function registerApi({
|
|||||||
firstname,
|
firstname,
|
||||||
lastname,
|
lastname,
|
||||||
password,
|
password,
|
||||||
|
confirmPassword,
|
||||||
birthdate,
|
birthdate,
|
||||||
gender,
|
gender,
|
||||||
marketingOptIn,
|
marketingOptIn,
|
||||||
@@ -38,6 +39,7 @@ export async function registerApi({
|
|||||||
firstname,
|
firstname,
|
||||||
lastname,
|
lastname,
|
||||||
password,
|
password,
|
||||||
|
confirmPassword,
|
||||||
birthdate: birthdate || undefined,
|
birthdate: birthdate || undefined,
|
||||||
gender: gender || undefined,
|
gender: gender || undefined,
|
||||||
marketingOptIn: marketingOptIn ?? undefined,
|
marketingOptIn: marketingOptIn ?? undefined,
|
||||||
@@ -66,11 +68,23 @@ export async function verifyCodeApi({ email, code }) {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function forgotPasswordApi(email) {
|
export async function forgotPasswordApi({ email, captchaToken }) {
|
||||||
const res = await fetch(`${BASE}/forgot-password`, {
|
const res = await fetch(`${BASE}/forgot-password`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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()
|
const data = await res.json()
|
||||||
if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status)
|
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
|
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 }) {
|
export async function changePasswordApi({ currentPassword, newPassword }) {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const res = await fetch(`${BASE}/change-password`, {
|
const res = await fetch(`${BASE}/change-password`, {
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ function apiError(message, status) {
|
|||||||
return err
|
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() {
|
export async function myGainsApi() {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const res = await fetch(`${BASE}/my-gains`, {
|
const res = await fetch(`${BASE}/my-gains`, {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import '@testing-library/jest-dom'
|
|||||||
import { afterEach } from 'vitest'
|
import { afterEach } from 'vitest'
|
||||||
import { cleanup } from '@testing-library/react'
|
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(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
|
|||||||
41
frontend/src/test/fonctionnel/Accueil.test.jsx
Normal file
41
frontend/src/test/fonctionnel/Accueil.test.jsx
Normal file
@@ -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(<Accueil />, { 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(<Accueil />, { 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
56
frontend/src/test/fonctionnel/DefinirMotDePasse.test.jsx
Normal file
56
frontend/src/test/fonctionnel/DefinirMotDePasse.test.jsx
Normal file
@@ -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(<DefinirMotDePasse />, { 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(<DefinirMotDePasse />, { 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(<DefinirMotDePasse />, { 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(<DefinirMotDePasse />, { route: '/definir-mot-de-passe/abc' })
|
||||||
|
|
||||||
|
await remplir('MonMotDePasse1!')
|
||||||
|
|
||||||
|
expect(resetPasswordApi).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ password: 'MonMotDePasse1!', confirmPassword: 'MonMotDePasse1!' })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
97
frontend/src/test/fonctionnel/EmployeeDeliveries.test.jsx
Normal file
97
frontend/src/test/fonctionnel/EmployeeDeliveries.test.jsx
Normal file
@@ -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(<EmployeeDeliveries refreshKey={0} onAuthError={() => 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,13 +10,21 @@ vi.mock('../../services/auth', () => ({
|
|||||||
verifyCodeApi: vi.fn(),
|
verifyCodeApi: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Remplit et soumet le formulaire d'inscription (étape 1).
|
// Remplit les champs obligatoires (la date de naissance l'est depuis la règle
|
||||||
async function remplirEtSoumettre() {
|
// 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(/adresse email/i), 'jean@example.com')
|
||||||
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
|
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
|
||||||
await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean')
|
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 }))
|
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 }))
|
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(/^nom/i)).toBeInTheDocument()
|
||||||
expect(screen.getByLabelText(/prénom/i)).toBeInTheDocument()
|
expect(screen.getByLabelText(/prénom/i)).toBeInTheDocument()
|
||||||
expect(screen.getByLabelText(/^mot de passe/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(<Inscription />, { 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(<Inscription />, { 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(<Inscription />, { 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(<Inscription />, { 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 () => {
|
it("désactive le bouton tant que les conditions ne sont pas acceptées", async () => {
|
||||||
@@ -64,9 +116,11 @@ describe('Inscription', () => {
|
|||||||
firstname: 'Jean',
|
firstname: 'Jean',
|
||||||
lastname: 'Dupont',
|
lastname: 'Dupont',
|
||||||
password: 'MotDePasse123!',
|
password: 'MotDePasse123!',
|
||||||
birthdate: '',
|
confirmPassword: 'MotDePasse123!',
|
||||||
|
birthdate: '1990-05-14',
|
||||||
gender: '',
|
gender: '',
|
||||||
marketingOptIn: false,
|
marketingOptIn: false,
|
||||||
|
captchaToken: '',
|
||||||
})
|
})
|
||||||
expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument()
|
||||||
expect(screen.getByText('jean@example.com')).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.type(screen.getByLabelText(/date de naissance/i), '1990-05-14')
|
||||||
await userEvent.selectOptions(screen.getByLabelText(/genre/i), 'Femme')
|
await userEvent.selectOptions(screen.getByLabelText(/genre/i), 'Femme')
|
||||||
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
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('checkbox', { name: /conditions d'utilisation/i }))
|
||||||
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/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 })
|
const marketing = screen.getByRole('checkbox', { name: /offres et actualités/i })
|
||||||
expect(marketing).not.toBeChecked()
|
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(marketing)
|
||||||
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
await remplirEtSoumettre()
|
||||||
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
|
|
||||||
|
|
||||||
expect(registerApi).toHaveBeenCalledWith(
|
expect(registerApi).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ marketingOptIn: true })
|
expect.objectContaining({ marketingOptIn: true })
|
||||||
|
|||||||
51
frontend/src/test/fonctionnel/MotDePasseOublie.test.jsx
Normal file
51
frontend/src/test/fonctionnel/MotDePasseOublie.test.jsx
Normal file
@@ -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(<MotDePasseOublie />, { 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(<MotDePasseOublie />, { 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(<Connexion />, {
|
||||||
|
route: '/connexion',
|
||||||
|
routes: { '/mot-de-passe-oublie': <MotDePasseOublie /> },
|
||||||
|
})
|
||||||
|
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
74
frontend/src/test/fonctionnel/NouveauMotDePasse.test.jsx
Normal file
74
frontend/src/test/fonctionnel/NouveauMotDePasse.test.jsx
Normal file
@@ -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(<NouveauMotDePasse />, { 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(<NouveauMotDePasse />, {
|
||||||
|
route: '/nouveau-mot-de-passe',
|
||||||
|
user: EMPLOYE,
|
||||||
|
routes: { '/employe': <p>Page Employé</p> },
|
||||||
|
})
|
||||||
|
|
||||||
|
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(<NouveauMotDePasse />, { 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(<NouveauMotDePasse />, { 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(<NouveauMotDePasse />, {
|
||||||
|
route: '/nouveau-mot-de-passe',
|
||||||
|
user: { ...EMPLOYE, mustChangePassword: false },
|
||||||
|
routes: { '/employe': <p>Page Employé</p> },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(screen.getByText('Page Employé')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -77,4 +77,20 @@ describe('ProtectedRoute', () => {
|
|||||||
|
|
||||||
expect(screen.getByText('Page Employé')).toBeInTheDocument()
|
expect(screen.getByText('Page Employé')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('impose le choix du mot de passe tant que le provisoire n\'est pas remplacé', () => {
|
||||||
|
renderWithProviders(
|
||||||
|
<ProtectedRoute roles={['EMPLOYEE']}>
|
||||||
|
<p>Espace employé</p>
|
||||||
|
</ProtectedRoute>,
|
||||||
|
{
|
||||||
|
route: '/employe',
|
||||||
|
user: { id: 3, email: 'employe@example.com', role: 'EMPLOYEE', mustChangePassword: true },
|
||||||
|
routes: { '/nouveau-mot-de-passe': <p>Page Nouveau mot de passe</p> },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Page Nouveau mot de passe')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Espace employé')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
37
frontend/src/test/unit/gameDates.test.js
Normal file
37
frontend/src/test/unit/gameDates.test.js
Normal file
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
80
frontend/src/utils/gameDates.js
Normal file
80
frontend/src/utils/gameDates.js
Normal file
@@ -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}`
|
||||||
|
}
|
||||||
14
frontend/src/utils/passwordRules.js
Normal file
14
frontend/src/utils/passwordRules.js
Normal file
@@ -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.'
|
||||||
Reference in New Issue
Block a user