changement

This commit is contained in:
Emma781227
2026-09-14 22:30:40 +02:00
parent 8a6355cde1
commit 2201446ee6
54 changed files with 2495 additions and 139 deletions

View File

@@ -2,7 +2,8 @@ const bcrypt = require("bcrypt");
const crypto = require("crypto");
const prisma = require("../config/prisma");
const transporter = require("../config/mailer");
const { getGameWindow } = require("../utils/gameWindow");
const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow");
const getFrontendUrl = require("../utils/frontendUrl");
const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"];
@@ -166,9 +167,30 @@ const getEmailingContacts = async (req, res) => {
}
};
// Sans caractères ambigus (0/O, 1/l/I) : l'employé peut devoir le recopier.
const TEMP_PASSWORD_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
// Aucun caractère à échapper dans l'email HTML (&, <, >, guillemets).
const TEMP_PASSWORD_SPECIALS = "!@#$%*?";
const TEMP_PASSWORD_LENGTH = 12;
// Respecte la règle commune des mots de passe (au moins un caractère spécial),
// placé à une position aléatoire.
function generateTempPassword() {
const chars = [];
for (let i = 0; i < TEMP_PASSWORD_LENGTH - 1; i++) {
chars.push(TEMP_PASSWORD_ALPHABET[crypto.randomInt(TEMP_PASSWORD_ALPHABET.length)]);
}
const special = TEMP_PASSWORD_SPECIALS[crypto.randomInt(TEMP_PASSWORD_SPECIALS.length)];
chars.splice(crypto.randomInt(chars.length + 1), 0, special);
return chars.join("");
}
// L'admin ne choisit pas le mot de passe de l'employé : on en génère un
// provisoire, envoyé uniquement par email, que l'employé devra remplacer à
// sa première connexion (mustChangePassword).
const createEmployee = async (req, res) => {
try {
const { firstname, lastname, email, password, birthdate, gender } = req.body;
const { firstname, lastname, email, birthdate, gender } = req.body;
const existingUser = await prisma.user.findUnique({
where: { email },
@@ -180,7 +202,8 @@ const createEmployee = async (req, res) => {
});
}
const hashedPassword = await bcrypt.hash(password, 10);
const tempPassword = generateTempPassword();
const hashedPassword = await bcrypt.hash(tempPassword, 10);
const employee = await prisma.user.create({
data: {
@@ -192,6 +215,8 @@ const createEmployee = async (req, res) => {
gender,
role: "EMPLOYEE",
marketingOptIn: false,
isEmailVerified: true,
mustChangePassword: true,
},
select: {
id: true,
@@ -203,8 +228,45 @@ const createEmployee = async (req, res) => {
},
});
const loginUrl = `${getFrontendUrl()}/connexion`;
try {
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: email,
subject: "Votre compte employé Thé Tip Top",
html: `
<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({
message: "Employé créé avec succès.",
message: "Employé créé. Un email avec un mot de passe provisoire lui a été envoyé.",
employee,
});
} catch (error) {
@@ -427,11 +489,137 @@ const getGrandPrizeWinner = async (req, res) => {
}
};
const PERSON_SELECT = { id: true, firstname: true, lastname: true, email: true };
// Comptes employés, avec le nombre de lots que chacun a remis. `q` filtre sur
// le prénom, le nom ou l'email.
const getEmployees = async (req, res) => {
try {
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
const contains = { contains: q, mode: "insensitive" };
const employees = await prisma.user.findMany({
where: {
role: "EMPLOYEE",
...(q && { OR: [{ firstname: contains }, { lastname: contains }, { email: contains }] }),
},
select: {
...PERSON_SELECT,
createdAt: true,
mustChangePassword: true,
_count: { select: { claimsValidated: { where: { status: "DELIVERED" } } } },
},
orderBy: [{ lastname: "asc" }, { firstname: "asc" }],
});
return res.json({
employees: employees.map(({ _count, ...employee }) => ({
...employee,
deliveredCount: _count.claimsValidated,
})),
});
} catch (error) {
console.error("Erreur liste employés:", error);
return res.status(500).json({ message: "Erreur serveur lors de la récupération des employés." });
}
};
// Lots remis par un employé, du plus récent au plus ancien.
const getEmployeeDeliveries = async (req, res) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
return res.status(400).json({ message: "Identifiant d'employé invalide." });
}
const employee = await prisma.user.findFirst({
where: { id, role: "EMPLOYEE" },
select: PERSON_SELECT,
});
if (!employee) {
return res.status(404).json({ message: "Employé introuvable." });
}
const claims = await prisma.claim.findMany({
where: { validatedByEmployeeId: id, status: "DELIVERED" },
orderBy: { claimedAt: "desc" },
include: {
participation: {
include: {
ticket: { select: { code: true } },
lot: { select: { name: true, value: true } },
user: { select: PERSON_SELECT },
},
},
},
});
return res.json({
employee,
deliveries: claims.map((claim) => ({
claimId: claim.id,
ticketCode: claim.participation.ticket.code,
lot: claim.participation.lot,
client: claim.participation.user,
deliveredAt: claim.claimedAt,
})),
});
} catch (error) {
console.error("Erreur remises employé:", error);
return res.status(500).json({ message: "Erreur serveur lors de la récupération des remises." });
}
};
// Traçabilité d'un ticket : qui l'a joué, quel lot, et quel employé l'a remis
// et quand. `status` vaut NOT_PLAYED tant qu'aucun client ne l'a saisi.
const getDeliveryByTicketCode = async (req, res) => {
try {
const code = req.params.code.trim().toUpperCase();
const gameWindow = await getGameWindow();
const ticket = await prisma.ticket.findUnique({
where: { code },
include: {
lot: { select: { name: true, value: true } },
participation: {
include: {
user: { select: PERSON_SELECT },
claim: { include: { validatedByEmployee: { select: PERSON_SELECT } } },
},
},
},
});
if (!ticket) {
return res.status(404).json({ message: "Aucun ticket ne correspond à ce numéro." });
}
const claim = ticket.participation?.claim;
return res.json({
ticketCode: ticket.code,
lot: ticket.lot,
status: claim ? effectiveClaimStatus(claim.status, gameWindow) : "NOT_PLAYED",
playedAt: ticket.usedAt,
client: ticket.participation?.user ?? null,
deliveredBy: claim?.validatedByEmployee ?? null,
deliveredAt: claim?.status === "DELIVERED" ? claim.claimedAt : null,
});
} catch (error) {
console.error("Erreur recherche remise:", error);
return res.status(500).json({ message: "Erreur serveur lors de la recherche du ticket." });
}
};
module.exports = {
getStats,
getUsers,
getEmailingContacts,
createEmployee,
getEmployees,
getEmployeeDeliveries,
getDeliveryByTicketCode,
drawWinner,
getGrandPrizeWinner,
setGameSettings,

View File

@@ -4,6 +4,8 @@ const crypto = require("crypto");
const prisma = require("../config/prisma");
const transporter = require("../config/mailer");
const getFrontendUrl = require("../utils/frontendUrl");
const { passwordRuleError } = require("../validations/password.validation");
const { normalizeEmail } = require("../validations/email.validation");
const register = async (req, res) => {
try {
@@ -211,6 +213,7 @@ const login = async (req, res) => {
lastname: user.lastname,
email: user.email,
role: user.role,
mustChangePassword: user.mustChangePassword,
},
});
} catch (error) {
@@ -221,9 +224,47 @@ const login = async (req, res) => {
}
};
const RESET_LINK_EXPIRY_MS = 1000 * 60 * 30; // 30 min
// Enregistre un jeton de réinitialisation (qui remplace le précédent) et envoie
// le lien par email. Partagé par « mot de passe oublié » et par le profil.
async function sendPasswordResetEmail(user) {
const resetPasswordToken = crypto.randomBytes(32).toString("hex");
await prisma.user.update({
where: { id: user.id },
data: {
resetPasswordToken,
resetPasswordExpires: new Date(Date.now() + RESET_LINK_EXPIRY_MS),
},
});
// Le lien doit ouvrir le formulaire du front, pas la route POST de l'API :
// un clic depuis un email est une requête GET, qui tombait en 404.
const resetUrl = `${getFrontendUrl()}/definir-mot-de-passe/${resetPasswordToken}`;
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: user.email,
subject: "Réinitialisation de votre mot de passe Thé Tip Top",
html: `
<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) => {
try {
const { email } = req.body;
const email = normalizeEmail(req.body.email);
if (!email) {
return res.status(400).json({
@@ -237,45 +278,12 @@ const forgotPassword = async (req, res) => {
// Sécurité : ne pas dire si l'email existe ou non
if (!user) {
return res.status(200).json({
message: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.",
});
return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE });
}
const resetPasswordToken = crypto.randomBytes(32).toString("hex");
const resetPasswordExpires = new Date(Date.now() + 1000 * 60 * 30); // 30 min
await sendPasswordResetEmail(user);
await prisma.user.update({
where: { id: user.id },
data: {
resetPasswordToken,
resetPasswordExpires,
},
});
// Le lien doit ouvrir le formulaire du front, pas la route POST de l'API :
// un clic depuis un email est une requête GET, qui tombait en 404.
const frontendUrl = getFrontendUrl();
const resetUrl = `${frontendUrl}/definir-mot-de-passe/${resetPasswordToken}`;
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: user.email,
subject: "Réinitialisation de votre mot de passe Thé Tip Top",
html: `
<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é.",
});
return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE });
} catch (error) {
console.error("Erreur forgotPassword:", error);
return res.status(500).json({
@@ -284,15 +292,27 @@ const forgotPassword = async (req, res) => {
}
};
// Profil connecté (compte Google sans mot de passe) : même lien, envoyé à
// l'adresse du compte. Authentifié, donc sans captcha.
const sendPasswordSetupLink = async (req, res) => {
try {
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
await sendPasswordResetEmail(user);
return res.json({ message: `Lien envoyé à ${user.email}.` });
} catch (error) {
console.error("Erreur sendPasswordSetupLink:", error);
return res.status(500).json({ message: "Erreur serveur lors de l'envoi du lien." });
}
};
const resetPassword = async (req, res) => {
try {
const { token } = req.params;
const { password, confirmPassword } = req.body;
if (!password || password.length < 8) {
return res.status(400).json({
message: "Le nouveau mot de passe doit contenir au moins 8 caractères.",
});
const passwordError = passwordRuleError(password);
if (passwordError) {
return res.status(400).json({ message: passwordError });
}
// La confirmation est vérifiée côté serveur aussi : le contrôle du
@@ -326,6 +346,14 @@ const resetPassword = async (req, res) => {
password: hashedPassword,
resetPasswordToken: null,
resetPasswordExpires: null,
// Le lien reçu par email prouve l'accès à la boîte : le mot de passe
// choisi ici remplace aussi un éventuel mot de passe provisoire, et
// l'adresse est considérée comme validée (sinon la connexion qui suit
// serait refusée pour email non vérifié).
mustChangePassword: false,
isEmailVerified: true,
emailVerifyToken: null,
emailVerifyExpires: null,
},
});
@@ -342,7 +370,8 @@ const resetPassword = async (req, res) => {
const verifyCode = async (req, res) => {
try {
const { email, code } = req.body;
const { code } = req.body;
const email = normalizeEmail(req.body.email);
if (!email || !code) {
return res.status(400).json({ message: "Email et code requis." });
@@ -424,6 +453,7 @@ const oauthCallback = (req, res) => {
lastname: user.lastname,
email: user.email,
role: user.role,
mustChangePassword: user.mustChangePassword,
})
).toString("base64");
@@ -491,6 +521,47 @@ const deleteAccount = async (req, res) => {
}
};
// Première connexion d'un employé : remplace le mot de passe provisoire reçu
// par email. L'utilisateur vient de s'authentifier avec ce mot de passe, on ne
// le redemande donc pas.
const setInitialPassword = async (req, res) => {
try {
const { password, confirmPassword } = req.body;
if (!req.user.mustChangePassword) {
return res.status(400).json({ message: "Votre mot de passe a déjà été défini." });
}
const passwordError = passwordRuleError(password);
if (passwordError) {
return res.status(400).json({ message: passwordError });
}
if (password !== confirmPassword) {
return res.status(400).json({ message: "Les deux mots de passe ne correspondent pas." });
}
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
if (user.password && (await bcrypt.compare(password, user.password))) {
return res.status(400).json({
message: "Choisissez un mot de passe différent du mot de passe provisoire.",
});
}
const hashed = await bcrypt.hash(password, 10);
await prisma.user.update({
where: { id: user.id },
data: { password: hashed, mustChangePassword: false },
});
return res.json({ message: "Mot de passe enregistré." });
} catch (error) {
console.error("Erreur setInitialPassword:", error);
return res.status(500).json({ message: "Erreur serveur lors de l'enregistrement du mot de passe." });
}
};
const changePassword = async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
@@ -500,8 +571,9 @@ const changePassword = async (req, res) => {
return res.status(400).json({ message: "Les deux mots de passe sont requis." });
}
if (newPassword.length < 8) {
return res.status(400).json({ message: "Le nouveau mot de passe doit faire au moins 8 caractères." });
const passwordError = passwordRuleError(newPassword);
if (passwordError) {
return res.status(400).json({ message: passwordError });
}
const user = await prisma.user.findUnique({ where: { id: userId } });
@@ -536,7 +608,9 @@ module.exports = {
verifyCode,
resetPassword,
forgotPassword,
sendPasswordSetupLink,
changePassword,
setInitialPassword,
deleteAccount,
oauthCallback,
};

View File

@@ -185,7 +185,20 @@ const myGains = async (req, res) => {
}
};
// Dates publiques du jeu-concours, affichées sur la page d'accueil : elles
// suivent la date de début fixée par l'admin.
const getPublicGameWindow = async (req, res) => {
try {
const { configured, phase, startDate, participationDeadline, claimDeadline } = await getGameWindow();
return res.json({ configured, phase, startDate, participationDeadline, claimDeadline });
} catch (error) {
console.error("Erreur lecture dates du jeu:", error);
return res.status(500).json({ message: "Erreur serveur lors de la lecture des dates du jeu-concours." });
}
};
module.exports = {
participate,
myGains,
getPublicGameWindow,
};