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: `

Bienvenue ${firstname} !

Merci pour votre inscription sur Thé Tip Top.

Voici votre code de validation à 4 chiffres :

${emailVerifyToken}

Ce code expire dans 15 minutes.

`, }); } catch (mailError) { console.error("Erreur envoi du code de validation:", mailError); try { await prisma.user.delete({ where: { id: user.id } }); } catch (rollbackError) { console.error("Echec de l'annulation de l'inscription:", rollbackError); } return res.status(502).json({ message: "Votre compte n'a pas pu être créé : l'email de validation n'a pas pu être envoyé. Merci de réessayer dans quelques instants.", }); } return res.status(201).json({ message: "Inscription réussie. Un code de validation a été envoyé par email.", user, }); } catch (error) { console.error("Erreur register:", error); return res.status(500).json({ message: "Erreur serveur lors de l'inscription.", }); } }; const verifyEmail = async (req, res) => { try { const { token } = req.params; const user = await prisma.user.findFirst({ where: { emailVerifyToken: token, emailVerifyExpires: { gt: new Date(), }, }, }); if (!user) { return res.status(400).json({ message: "Lien de validation invalide ou expiré.", }); } await prisma.user.update({ where: { id: user.id }, data: { isEmailVerified: true, emailVerifyToken: null, emailVerifyExpires: null, }, }); return res.status(200).json({ message: "Email validé avec succès. Vous pouvez maintenant vous connecter.", }); } catch (error) { console.error("Erreur verifyEmail:", error); return res.status(500).json({ message: "Erreur serveur lors de la validation de l'email.", }); } }; const login = async (req, res) => { try { const { email, password } = req.body; if (!email || !password) { return res.status(400).json({ message: "Email et mot de passe requis.", }); } const user = await prisma.user.findUnique({ where: { email }, }); if (!user) { return res.status(401).json({ message: "Identifiants invalides.", }); } if (!user.isEmailVerified && user.role === "CLIENT") { return res.status(403).json({ message: "Veuillez valider votre adresse email avant de vous connecter.", }); } // Compte créé via un provider externe : aucun mot de passe à comparer. if (!user.password) { const providerLabel = user.provider === "FACEBOOK" ? "Facebook" : "Google"; return res.status(401).json({ message: `Ce compte utilise la connexion ${providerLabel}. Utilisez le bouton « ${providerLabel} » pour vous connecter.`, }); } const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { return res.status(401).json({ message: "Identifiants invalides.", }); } const token = jwt.sign( { id: user.id, role: user.role, }, process.env.JWT_SECRET, { expiresIn: "24h" } ); return res.json({ message: "Connexion réussie.", token, user: { id: user.id, firstname: user.firstname, lastname: user.lastname, email: user.email, role: user.role, mustChangePassword: user.mustChangePassword, }, }); } catch (error) { console.error("Erreur login:", error); return res.status(500).json({ message: "Erreur serveur lors de la connexion.", }); } }; const RESET_LINK_EXPIRY_MS = 1000 * 60 * 30; // 30 min // Enregistre un jeton de réinitialisation (qui remplace le précédent) et envoie // le lien par email. Partagé par « mot de passe oublié » et par le profil. async function sendPasswordResetEmail(user) { const resetPasswordToken = crypto.randomBytes(32).toString("hex"); await prisma.user.update({ where: { id: user.id }, data: { resetPasswordToken, resetPasswordExpires: new Date(Date.now() + RESET_LINK_EXPIRY_MS), }, }); // Le lien doit ouvrir le formulaire du front, pas la route POST de l'API : // un clic depuis un email est une requête GET, qui tombait en 404. const resetUrl = `${getFrontendUrl()}/definir-mot-de-passe/${resetPasswordToken}`; await transporter.sendMail({ from: process.env.MAIL_FROM, to: user.email, subject: "Réinitialisation de votre mot de passe Thé Tip Top", html: `

Réinitialisation du mot de passe

Bonjour ${user.firstname},

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

Cliquez sur le lien ci-dessous pour le choisir :

Choisir mon nouveau mot de passe

Ce lien expire dans 30 minutes.

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

`, }); } const FORGOT_PASSWORD_MESSAGE = "Si un compte existe avec cet email, un lien de réinitialisation vient de lui être envoyé."; const forgotPassword = async (req, res) => { try { const email = 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, };