616 lines
18 KiB
JavaScript
616 lines
18 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 { 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: `
|
|
<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,
|
|
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: `
|
|
<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 = 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,
|
|
}; |