const bcrypt = require("bcrypt"); const jwt = require("jsonwebtoken"); 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 { const { firstname, lastname, email, password, birthdate, gender, marketingOptIn, } = req.body; if (!firstname || !lastname || !email || !password) { return res.status(400).json({ message: "Tous les champs obligatoires doivent être renseignés.", }); } const existingUser = await prisma.user.findUnique({ where: { email }, }); if (existingUser) { return res.status(409).json({ message: "Un compte existe déjà avec cet email.", }); } const hashedPassword = await bcrypt.hash(password, 10); const emailVerifyToken = String(Math.floor(1000 + Math.random() * 9000)); const emailVerifyExpires = new Date(Date.now() + 1000 * 60 * 15); // 15 min const user = await prisma.user.create({ data: { firstname, lastname, email, password: hashedPassword, birthdate: birthdate ? new Date(birthdate) : null, gender, marketingOptIn: marketingOptIn || false, isEmailVerified: false, emailVerifyToken, emailVerifyExpires, }, select: { id: true, firstname: true, lastname: true, email: true, role: true, marketingOptIn: true, isEmailVerified: true, createdAt: true, }, }); // Le code de validation est le seul moyen d'activer le compte. Si l'envoi // échoue, laisser l'utilisateur en base le bloquerait définitivement : il // ne pourrait ni se connecter (email non vérifié) ni se réinscrire (email // déjà pris). On annule donc la création pour que la tentative reste // rejouable. try { await transporter.sendMail({ from: process.env.MAIL_FROM, to: email, subject: "Votre code de validation Thé Tip Top", html: `
Merci pour votre inscription sur Thé Tip Top.
Voici votre code de validation à 4 chiffres :
Ce code expire dans 15 minutes.
Bonjour ${user.firstname},
Vous avez demandé à définir un nouveau mot de passe.
Cliquez sur le lien ci-dessous pour le choisir :
Choisir mon nouveau mot de passeCe lien expire dans 30 minutes.
Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.
`, }); } const FORGOT_PASSWORD_MESSAGE = "Si un compte existe avec cet email, un lien de réinitialisation vient de lui être envoyé."; const forgotPassword = async (req, res) => { try { const email = normalizeEmail(req.body.email); if (!email) { return res.status(400).json({ message: "Email requis.", }); } const user = await prisma.user.findUnique({ where: { email }, }); // Sécurité : ne pas dire si l'email existe ou non if (!user) { return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE }); } await sendPasswordResetEmail(user); return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE }); } catch (error) { console.error("Erreur forgotPassword:", error); return res.status(500).json({ message: "Erreur serveur lors de la demande de réinitialisation.", }); } }; // 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; 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 // navigateur ne protège pas un appel direct à l'API. if (confirmPassword !== undefined && password !== confirmPassword) { return res.status(400).json({ message: "Les deux mots de passe ne correspondent pas.", }); } const user = await prisma.user.findFirst({ where: { resetPasswordToken: token, resetPasswordExpires: { gt: new Date(), }, }, }); if (!user) { return res.status(400).json({ message: "Lien de réinitialisation invalide ou expiré.", }); } const hashedPassword = await bcrypt.hash(password, 10); await prisma.user.update({ where: { id: user.id }, data: { 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, }, }); return res.status(200).json({ message: "Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter.", }); } catch (error) { console.error("Erreur resetPassword:", error); return res.status(500).json({ message: "Erreur serveur lors de la réinitialisation du mot de passe.", }); } }; const verifyCode = async (req, res) => { try { const { code } = req.body; const email = normalizeEmail(req.body.email); if (!email || !code) { return res.status(400).json({ message: "Email et code requis." }); } const user = await prisma.user.findUnique({ where: { email } }); if (!user) { return res.status(404).json({ message: "Compte introuvable." }); } if (user.isEmailVerified) { return res.status(400).json({ message: "Ce compte est déjà vérifié." }); } if (!user.emailVerifyToken || user.emailVerifyToken !== String(code)) { return res.status(400).json({ message: "Code invalide." }); } if (new Date() > user.emailVerifyExpires) { return res.status(400).json({ message: "Code expiré. Veuillez vous réinscrire." }); } await prisma.user.update({ where: { id: user.id }, data: { isEmailVerified: true, emailVerifyToken: null, emailVerifyExpires: null }, }); const token = jwt.sign( { id: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: "24h" } ); return res.json({ message: "Compte vérifié avec succès.", token, user: { id: user.id, firstname: user.firstname, lastname: user.lastname, email: user.email, role: user.role, }, }); } catch (error) { console.error("Erreur verifyCode:", error); return res.status(500).json({ message: "Erreur serveur lors de la vérification." }); } }; // Fin du parcours OAuth : passport a posé l'utilisateur sur req.user, on émet // le même JWT que la connexion classique et on renvoie le navigateur vers le // front, qui récupère le token dans l'URL. const oauthCallback = (req, res) => { const frontendUrl = getFrontendUrl(); try { const user = req.user; if (!user) { return res.redirect( `${frontendUrl}/auth/callback?error=${encodeURIComponent( "Connexion externe refusée." )}` ); } const token = jwt.sign( { id: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: "24h" } ); const payload = Buffer.from( JSON.stringify({ id: user.id, firstname: user.firstname, lastname: user.lastname, email: user.email, role: user.role, mustChangePassword: user.mustChangePassword, }) ).toString("base64"); // encodeURIComponent : le base64 peut contenir des « + » que le front // relirait comme des espaces dans la query string. return res.redirect( `${frontendUrl}/auth/callback?token=${token}&user=${encodeURIComponent(payload)}` ); } catch (error) { console.error("Erreur oauthCallback:", error); return res.redirect( `${frontendUrl}/auth/callback?error=${encodeURIComponent( "Erreur serveur lors de la connexion externe." )}` ); } }; const me = async (req, res) => { return res.json({ user: req.user, }); }; const deleteAccount = async (req, res) => { try { const userId = req.user.id; await prisma.$transaction(async (tx) => { // 1. Supprimer le grand gagnant si c'est lui await tx.grandPrizeWinner.deleteMany({ where: { userId } }); // 2. Récupérer les participations pour supprimer leurs claims const participations = await tx.participation.findMany({ where: { userId }, select: { id: true }, }); const participationIds = participations.map((p) => p.id); // 3. Supprimer les claims if (participationIds.length > 0) { await tx.claim.deleteMany({ where: { participationId: { in: participationIds } } }); } // 4. Supprimer les participations await tx.participation.deleteMany({ where: { userId } }); // 5. Détacher les tickets du compte sans les remettre en jeu : un code // figure sur un ticket de caisse physique et n'est jouable qu'une // fois. Le réinitialiser permettrait de rejouer indéfiniment le même // code en supprimant puis recréant un compte. await tx.ticket.updateMany({ where: { userId }, data: { userId: null }, }); // 6. Supprimer l'utilisateur await tx.user.delete({ where: { id: userId } }); }); return res.json({ message: "Compte supprimé avec succès." }); } catch (error) { console.error("Erreur deleteAccount:", error); return res.status(500).json({ message: "Erreur serveur lors de la suppression du compte." }); } }; // 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; const userId = req.user.id; if (!currentPassword || !newPassword) { return res.status(400).json({ message: "Les deux mots de passe sont requis." }); } const passwordError = passwordRuleError(newPassword); if (passwordError) { return res.status(400).json({ message: passwordError }); } const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user.password) { return res.status(400).json({ message: "Ce compte utilise une connexion externe et n'a pas de mot de passe à modifier.", }); } const isValid = await bcrypt.compare(currentPassword, user.password); if (!isValid) { return res.status(401).json({ message: "Mot de passe actuel incorrect." }); } const hashed = await bcrypt.hash(newPassword, 10); await prisma.user.update({ where: { id: userId }, data: { password: hashed } }); return res.json({ message: "Mot de passe modifié avec succès." }); } catch (error) { console.error("Erreur changePassword:", error); return res.status(500).json({ message: "Erreur serveur lors du changement de mot de passe." }); } }; module.exports = { register, login, me, verifyEmail, verifyCode, resetPassword, forgotPassword, sendPasswordSetupLink, changePassword, setInitialPassword, deleteAccount, oauthCallback, };