Files
THE-TIP-TOP/backend/src/controllers/auth.controller.js
2026-09-14 21:08:04 +02:00

542 lines
15 KiB
JavaScript

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 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: `
<div style="font-family:sans-serif;max-width:480px;margin:auto">
<h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
<p>Merci pour votre inscription sur Thé Tip Top.</p>
<p>Voici votre code de validation à 4 chiffres :</p>
<div style="font-size:36px;font-weight:bold;letter-spacing:12px;color:#006B6B;text-align:center;padding:20px 0">
${emailVerifyToken}
</div>
<p style="color:#888;font-size:13px">Ce code expire dans <strong>15 minutes</strong>.</p>
</div>
`,
});
} 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,
},
});
} catch (error) {
console.error("Erreur login:", error);
return res.status(500).json({
message: "Erreur serveur lors de la connexion.",
});
}
};
const forgotPassword = async (req, res) => {
try {
const { email } = req.body;
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: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.",
});
}
const resetPasswordToken = crypto.randomBytes(32).toString("hex");
const resetPasswordExpires = new Date(Date.now() + 1000 * 60 * 30); // 30 min
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é.",
});
} catch (error) {
console.error("Erreur forgotPassword:", error);
return res.status(500).json({
message: "Erreur serveur lors de la demande de réinitialisation.",
});
}
};
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.",
});
}
// 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,
},
});
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 { email, code } = req.body;
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,
})
).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." });
}
};
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." });
}
if (newPassword.length < 8) {
return res.status(400).json({ message: "Le nouveau mot de passe doit faire au moins 8 caractères." });
}
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,
changePassword,
deleteAccount,
oauthCallback,
};