change mail + change password ok !

This commit is contained in:
Arthur Barre 2025-03-13 22:31:10 +01:00
parent 223b593495
commit 7c2374f0b3
10 changed files with 727 additions and 160 deletions

View File

@ -12,10 +12,13 @@
"@fastify/jwt": "^7.0.0",
"@fastify/multipart": "^8.0.0",
"@prisma/client": "^5.0.0",
"bcrypt": "^5.1.0",
"bcrypt": "^5.1.1",
"crypto": "^1.0.1",
"dotenv": "^16.3.1",
"fastify": "^4.19.0",
"fastify-plugin": "^4.5.0",
"google-auth-library": "^9.15.1",
"nodemailer": "^6.10.0",
"openai": "^4.0.0",
"stripe": "^12.12.0"
},
@ -432,6 +435,26 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bcrypt": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
@ -446,6 +469,15 @@
"node": ">= 10.0.0"
}
},
"node_modules/bignumber.js": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz",
"integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -488,6 +520,12 @@
"node": ">=8"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@ -593,6 +631,13 @@
"node": ">= 0.6"
}
},
"node_modules/crypto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/crypto/-/crypto-1.0.1.tgz",
"integrity": "sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig==",
"deprecated": "This package is no longer supported. It's now a built-in Node module. If you've depended on crypto, you should switch to the one that's built-in.",
"license": "ISC"
},
"node_modules/debug": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
@ -729,6 +774,12 @@
"node": ">=6"
}
},
"node_modules/extend": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-content-type-parse": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-1.1.0.tgz",
@ -1044,6 +1095,58 @@
"node": ">=10"
}
},
"node_modules/gaxios": {
"version": "6.7.1",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.1.tgz",
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
"license": "Apache-2.0",
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^7.0.1",
"is-stream": "^2.0.0",
"node-fetch": "^2.6.9",
"uuid": "^9.0.1"
},
"engines": {
"node": ">=14"
}
},
"node_modules/gaxios/node_modules/agent-base": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/gaxios/node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/gcp-metadata": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
"integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
"license": "Apache-2.0",
"dependencies": {
"gaxios": "^6.1.1",
"google-logging-utils": "^0.0.2",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@ -1115,6 +1218,32 @@
"node": ">= 6"
}
},
"node_modules/google-auth-library": {
"version": "9.15.1",
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.15.1.tgz",
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
"license": "Apache-2.0",
"dependencies": {
"base64-js": "^1.3.0",
"ecdsa-sig-formatter": "^1.0.11",
"gaxios": "^6.1.1",
"gcp-metadata": "^6.1.0",
"gtoken": "^7.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=14"
}
},
"node_modules/google-logging-utils": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz",
"integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=14"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@ -1127,6 +1256,19 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gtoken": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/gtoken/-/gtoken-7.1.0.tgz",
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
"license": "MIT",
"dependencies": {
"gaxios": "^6.0.0",
"jws": "^4.0.0"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/has-flag": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
@ -1292,6 +1434,27 @@
"node": ">=0.12.0"
}
},
"node_modules/is-stream": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==",
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/json-bigint": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz",
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
"license": "MIT",
"dependencies": {
"bignumber.js": "^9.0.0"
}
},
"node_modules/json-schema-ref-resolver": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-1.0.1.tgz",
@ -1307,6 +1470,27 @@
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/jwa": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz",
"integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.0",
"safe-buffer": "^5.0.1"
}
},
"node_modules/light-my-request": {
"version": "5.14.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-5.14.0.tgz",
@ -1496,6 +1680,15 @@
}
}
},
"node_modules/nodemailer": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.0.tgz",
"integrity": "sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nodemon": {
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz",
@ -2215,6 +2408,19 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",

View File

@ -14,11 +14,13 @@
"@fastify/jwt": "^7.0.0",
"@fastify/multipart": "^8.0.0",
"@prisma/client": "^5.0.0",
"bcrypt": "^5.1.0",
"bcrypt": "^5.1.1",
"crypto": "^1.0.1",
"dotenv": "^16.3.1",
"fastify": "^4.19.0",
"fastify-plugin": "^4.5.0",
"google-auth-library": "^9.15.1",
"nodemailer": "^6.10.0",
"openai": "^4.0.0",
"stripe": "^12.12.0"
},

View File

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "resetToken" TEXT;
ALTER TABLE "User" ADD COLUMN "resetTokenExpiry" DATETIME;

View File

@ -15,6 +15,8 @@ model User {
googleId String? @unique // ID Google pour SSO
stripeId String @unique
subscription String?
resetToken String? // Token pour la réinitialisation du mot de passe
resetTokenExpiry DateTime? // Date d'expiration du token
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
recipes Recipe[]

298
backend/src/routes/users.js Normal file
View File

@ -0,0 +1,298 @@
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const { sendEmail } = require('../utils/email');
module.exports = async function (fastify, opts) {
// Middleware d'authentification pour les routes protégées
const authenticateUser = async (request, reply) => {
try {
await fastify.authenticate(request, reply);
} catch (err) {
reply.code(401).send({ error: 'Authentification requise' });
}
};
// Récupérer le profil utilisateur
fastify.get('/profile', { preHandler: authenticateUser }, async (request, reply) => {
try {
const user = await fastify.prisma.user.findUnique({
where: { id: request.user.id },
select: {
id: true,
email: true,
name: true,
subscription: true,
createdAt: true
}
});
if (!user) {
return reply.code(404).send({ error: 'Utilisateur non trouvé' });
}
return { user };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la récupération du profil' });
}
});
// Mettre à jour le profil utilisateur
fastify.put('/profile', { preHandler: authenticateUser }, async (request, reply) => {
try {
const { name } = request.body;
if (!name) {
return reply.code(400).send({ error: 'Le nom est requis' });
}
const updatedUser = await fastify.prisma.user.update({
where: { id: request.user.id },
data: { name },
select: {
id: true,
email: true,
name: true,
subscription: true
}
});
return { user: updatedUser };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la mise à jour du profil' });
}
});
// Changer le mot de passe (utilisateur connecté)
fastify.put('/change-password', { preHandler: authenticateUser }, async (request, reply) => {
try {
const { currentPassword, newPassword } = request.body;
if (!currentPassword || !newPassword) {
return reply.code(400).send({ error: 'Les mots de passe actuels et nouveaux sont requis' });
}
if (newPassword.length < 8) {
return reply.code(400).send({ error: 'Le nouveau mot de passe doit contenir au moins 8 caractères' });
}
// Vérifier l'utilisateur et son mot de passe actuel
const user = await fastify.prisma.user.findUnique({
where: { id: request.user.id }
});
if (!user || !user.password) {
return reply.code(400).send({ error: 'Utilisateur non trouvé ou connecté via Google' });
}
const isPasswordValid = await bcrypt.compare(currentPassword, user.password);
if (!isPasswordValid) {
return reply.code(400).send({ error: 'Mot de passe actuel incorrect' });
}
// Hasher et mettre à jour le nouveau mot de passe
const hashedPassword = await bcrypt.hash(newPassword, 10);
await fastify.prisma.user.update({
where: { id: request.user.id },
data: { password: hashedPassword }
});
return { success: true, message: 'Mot de passe mis à jour avec succès' };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors du changement de mot de passe' });
}
});
// Changer l'email (utilisateur connecté)
fastify.put('/change-email', { preHandler: authenticateUser }, async (request, reply) => {
try {
const { newEmail, password } = request.body;
if (!newEmail || !password) {
return reply.code(400).send({ error: 'Le nouvel email et le mot de passe sont requis' });
}
// Vérifier si l'email est déjà utilisé
const existingUser = await fastify.prisma.user.findUnique({
where: { email: newEmail }
});
if (existingUser) {
return reply.code(400).send({ error: 'Cet email est déjà utilisé' });
}
// Vérifier l'utilisateur et son mot de passe
const user = await fastify.prisma.user.findUnique({
where: { id: request.user.id }
});
if (!user || !user.password) {
return reply.code(400).send({ error: 'Utilisateur non trouvé ou connecté via Google' });
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return reply.code(400).send({ error: 'Mot de passe incorrect' });
}
// Mettre à jour l'email
await fastify.prisma.user.update({
where: { id: request.user.id },
data: { email: newEmail }
});
return { success: true, message: 'Email mis à jour avec succès' };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors du changement d\'email' });
}
});
// Demande de réinitialisation de mot de passe (utilisateur non connecté)
fastify.post('/forgot-password', async (request, reply) => {
try {
const { email } = request.body;
if (!email) {
return reply.code(400).send({ error: 'Email requis' });
}
// Vérifier si l'utilisateur existe
const user = await fastify.prisma.user.findUnique({
where: { email }
});
if (!user) {
// Pour des raisons de sécurité, ne pas indiquer si l'email existe ou non
return reply.code(200).send({
success: true,
message: 'Si un compte existe avec cet email, un lien de réinitialisation a été envoyé'
});
}
// Générer un token de réinitialisation
const resetToken = crypto.randomBytes(32).toString('hex');
const resetTokenExpiry = new Date(Date.now() + 3600000); // 1 heure
// Stocker le token dans la base de données
// Note: Il faudrait ajouter ces champs au modèle User dans schema.prisma
await fastify.prisma.user.update({
where: { id: user.id },
data: {
resetToken,
resetTokenExpiry
}
});
// Envoyer l'email avec le lien de réinitialisation
const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`;
// Fonction sendEmail à implémenter dans utils/email.js
await sendEmail({
to: user.email,
subject: 'Réinitialisation de votre mot de passe',
text: `Pour réinitialiser votre mot de passe, cliquez sur ce lien: ${resetUrl}`,
html: `<p>Pour réinitialiser votre mot de passe, cliquez sur ce lien: <a href="${resetUrl}">Réinitialiser mon mot de passe</a></p>`
});
return {
success: true,
message: 'Si un compte existe avec cet email, un lien de réinitialisation a été envoyé'
};
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la demande de réinitialisation de mot de passe' });
}
});
// Réinitialiser le mot de passe avec un token (utilisateur non connecté)
fastify.post('/reset-password', async (request, reply) => {
try {
const { token, newPassword } = request.body;
if (!token || !newPassword) {
return reply.code(400).send({ error: 'Token et nouveau mot de passe requis' });
}
if (newPassword.length < 8) {
return reply.code(400).send({ error: 'Le nouveau mot de passe doit contenir au moins 8 caractères' });
}
// Trouver l'utilisateur avec ce token
const user = await fastify.prisma.user.findFirst({
where: {
resetToken: token,
resetTokenExpiry: {
gt: new Date()
}
}
});
if (!user) {
return reply.code(400).send({ error: 'Token invalide ou expiré' });
}
// Hasher et mettre à jour le nouveau mot de passe
const hashedPassword = await bcrypt.hash(newPassword, 10);
await fastify.prisma.user.update({
where: { id: user.id },
data: {
password: hashedPassword,
resetToken: null,
resetTokenExpiry: null
}
});
return { success: true, message: 'Mot de passe réinitialisé avec succès' };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la réinitialisation du mot de passe' });
}
});
// Supprimer le compte utilisateur
fastify.delete('/account', { preHandler: authenticateUser }, async (request, reply) => {
try {
const { password } = request.body;
// Vérifier l'utilisateur et son mot de passe
const user = await fastify.prisma.user.findUnique({
where: { id: request.user.id }
});
if (!user) {
return reply.code(404).send({ error: 'Utilisateur non trouvé' });
}
// Si l'utilisateur a un mot de passe (pas connecté via Google), vérifier le mot de passe
if (user.password) {
if (!password) {
return reply.code(400).send({ error: 'Mot de passe requis' });
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
return reply.code(400).send({ error: 'Mot de passe incorrect' });
}
}
// Supprimer toutes les recettes de l'utilisateur
await fastify.prisma.recipe.deleteMany({
where: { userId: user.id }
});
// Supprimer l'utilisateur
await fastify.prisma.user.delete({
where: { id: user.id }
});
return { success: true, message: 'Compte supprimé avec succès' };
} catch (error) {
fastify.log.error(error);
return reply.code(500).send({ error: 'Erreur lors de la suppression du compte' });
}
});
};

View File

@ -22,7 +22,7 @@ fastify.register(require('./plugins/google-auth'));
// Routes
fastify.register(require('./routes/auth'), { prefix: '/auth' });
fastify.register(require('./routes/recipes'), { prefix: '/recipes' });
fastify.register(require('./routes/users'), { prefix: '/users' });
// Hook pour fermer la connexion Prisma à l'arrêt du serveur
fastify.addHook('onClose', async (instance, done) => {
await prisma.$disconnect();

View File

@ -0,0 +1,29 @@
const nodemailer = require('nodemailer');
// Configurer le transporteur d'emails
const transporter = nodemailer.createTransport({
host: process.env.EMAIL_HOST,
port: process.env.EMAIL_PORT,
secure: process.env.EMAIL_SECURE === 'true',
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASSWORD
}
});
// Fonction pour envoyer un email
async function sendEmail({ to, subject, text, html }) {
const mailOptions = {
from: process.env.EMAIL_FROM,
to,
subject,
text,
html
};
return transporter.sendMail(mailOptions);
}
module.exports = {
sendEmail
};

31
frontend/src/api/user.ts Normal file
View File

@ -0,0 +1,31 @@
import { apiService } from "./base";
const userService = {
getCurrentUser: async (): Promise<User> => {
const response = await apiService.get<{ user: User }>('users/profile');
return response.user;
},
updateProfile: async (data: { name: string }): Promise<User> => {
const response = await apiService.put<{ user: User }>('users/profile', data);
return response.user;
},
changePassword: async (data: { currentPassword: string; newPassword: string }): Promise<void> => {
return apiService.put('users/change-password', data);
},
changeEmail: async (data: { newEmail: string; password: string }): Promise<void> => {
return apiService.put('users/change-email', data);
},
deleteAccount: async (password: string): Promise<void> => {
return apiService.delete('users/account', { password });
},
logout: async (): Promise<void> => {
return apiService.post('auth/logout', {});
}
};
export default userService;

View File

@ -30,7 +30,7 @@ export function Header() {
{ name: "Recettes", path: "/recipes", icon: BookOpen, public: true },
// { name: "Mes recettes", path: "/recipes", icon: BookOpen, public: false },
// { name: "Favoris", path: "/favorites", icon: Heart, public: false },
// { name: "Profil", path: "/profile", icon: User, public: false },
{ name: "Profil", path: "/profile", icon: User, public: false },
]
const filteredNavItems = navItems.filter((item) => item.public || isAuthenticated)

View File

@ -8,50 +8,26 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Separator } from "@/components/ui/separator";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertCircle, Save, User, Lock, LogOut, Trash2 } from "lucide-react";
import { apiService } from "@/api/base";
import { AlertCircle, Save, User, Lock, LogOut, Trash2, Mail } from "lucide-react";
import { recipeService, Recipe } from "@/api/recipe";
import userService from "@/api/user";
// Types pour les données utilisateur
interface User {
id: string;
email: string;
username?: string;
firstName?: string;
lastName?: string;
bio?: string;
avatarUrl?: string;
name: string;
subscription?: string;
createdAt: string;
}
// Service utilisateur
const userService = {
getCurrentUser: async (): Promise<User> => {
return apiService.get<User>('users/me');
},
updateProfile: async (data: Partial<User>): Promise<User> => {
return apiService.put<User>('users/me', data);
},
changePassword: async (data: { currentPassword: string; newPassword: string }): Promise<void> => {
return apiService.post('users/change-password', data);
},
deleteAccount: async (): Promise<void> => {
return apiService.delete('users/me');
},
logout: async (): Promise<void> => {
return apiService.post('auth/logout', {});
}
};
export default function Profile() {
const navigate = useNavigate();
const [user, setUser] = useState<User | null>(null);
const [userRecipes, setUserRecipes] = useState<Recipe[]>([]);
const [favoriteRecipes, setFavoriteRecipes] = useState<Recipe[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
@ -59,10 +35,7 @@ export default function Profile() {
// États pour le formulaire de profil
const [profileForm, setProfileForm] = useState({
username: "",
firstName: "",
lastName: "",
bio: ""
name: ""
});
// États pour le formulaire de mot de passe
@ -72,6 +45,12 @@ export default function Profile() {
confirmPassword: ""
});
// États pour le formulaire de changement d'email
const [emailForm, setEmailForm] = useState({
newEmail: "",
password: ""
});
useEffect(() => {
const fetchUserData = async () => {
try {
@ -81,19 +60,12 @@ export default function Profile() {
const userData = await userService.getCurrentUser();
setUser(userData);
setProfileForm({
username: userData.username || "",
firstName: userData.firstName || "",
lastName: userData.lastName || "",
bio: userData.bio || ""
name: userData.name || ""
});
// Récupérer les recettes de l'utilisateur
const recipes = await recipeService.getUserRecipes();
const recipes = await recipeService.getRecipes();
setUserRecipes(recipes);
// Récupérer les recettes favorites
const favorites = await recipeService.getFavoriteRecipes();
setFavoriteRecipes(favorites);
} catch (err) {
console.error("Erreur lors du chargement du profil:", err);
setError("Impossible de charger les données du profil");
@ -111,7 +83,7 @@ export default function Profile() {
fetchUserData();
}, [navigate]);
const handleProfileChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const handleProfileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setProfileForm(prev => ({ ...prev, [name]: value }));
};
@ -121,6 +93,11 @@ export default function Profile() {
setPasswordForm(prev => ({ ...prev, [name]: value }));
};
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setEmailForm(prev => ({ ...prev, [name]: value }));
};
const handleProfileSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
@ -150,6 +127,12 @@ export default function Profile() {
return;
}
// Vérifier la longueur du mot de passe
if (passwordForm.newPassword.length < 8) {
setError("Le mot de passe doit contenir au moins 8 caractères");
return;
}
try {
setSaving(true);
await userService.changePassword({
@ -164,7 +147,47 @@ export default function Profile() {
});
} catch (err) {
console.error("Erreur lors du changement de mot de passe:", err);
setError("Impossible de changer le mot de passe");
setError("Impossible de changer le mot de passe. Vérifiez que votre mot de passe actuel est correct.");
} finally {
setSaving(false);
}
};
const handleEmailSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setSuccess("");
// Vérifier que l'email est valide
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(emailForm.newEmail)) {
setError("Veuillez entrer une adresse email valide");
return;
}
try {
setSaving(true);
await userService.changeEmail({
newEmail: emailForm.newEmail,
password: emailForm.password
});
setSuccess("Email modifié avec succès");
// Mettre à jour l'utilisateur avec le nouvel email
if (user) {
setUser({
...user,
email: emailForm.newEmail
});
}
setEmailForm({
newEmail: "",
password: ""
});
} catch (err) {
console.error("Erreur lors du changement d'email:", err);
setError("Impossible de changer l'email. Vérifiez que votre mot de passe est correct.");
} finally {
setSaving(false);
}
@ -182,17 +205,21 @@ export default function Profile() {
};
const handleDeleteAccount = async () => {
const password = prompt("Pour confirmer la suppression de votre compte, veuillez entrer votre mot de passe:");
if (!password) return;
if (!window.confirm("Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible.")) {
return;
}
try {
await userService.deleteAccount();
await userService.deleteAccount(password);
localStorage.removeItem("token");
navigate("/auth/login");
} catch (err) {
console.error("Erreur lors de la suppression du compte:", err);
setError("Impossible de supprimer le compte");
setError("Impossible de supprimer le compte. Vérifiez que votre mot de passe est correct.");
}
};
@ -259,26 +286,23 @@ export default function Profile() {
</CardHeader>
<CardContent className="flex flex-col items-center space-y-4">
<Avatar className="h-24 w-24">
<AvatarImage src={user.avatarUrl} alt={user.username || user.email} />
<AvatarImage src="" alt={user.name || user.email} />
<AvatarFallback className="text-2xl">
{user.firstName?.[0]}{user.lastName?.[0] || user.email[0].toUpperCase()}
{user.name ? user.name[0].toUpperCase() : user.email[0].toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="text-center">
<h3 className="text-xl font-bold">{user.username || "Utilisateur"}</h3>
<h3 className="text-xl font-bold">{user.name || "Utilisateur"}</h3>
<p className="text-sm text-muted-foreground">{user.email}</p>
{(user.firstName || user.lastName) && (
<p className="text-sm">{user.firstName} {user.lastName}</p>
)}
</div>
<div className="w-full">
<p className="text-sm text-muted-foreground">Membre depuis</p>
<p>{new Date(user.createdAt).toLocaleDateString()}</p>
</div>
{user.bio && (
{user.subscription && (
<div className="w-full">
<p className="text-sm text-muted-foreground">Bio</p>
<p className="text-sm">{user.bio}</p>
<p className="text-sm text-muted-foreground">Abonnement</p>
<p className="capitalize">{user.subscription}</p>
</div>
)}
</CardContent>
@ -297,14 +321,18 @@ export default function Profile() {
<div className="flex-1">
<Tabs defaultValue="profile">
<TabsList className="grid w-full grid-cols-2">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="profile">
<User className="mr-2 h-4 w-4" />
Profil
</TabsTrigger>
<TabsTrigger value="email">
<Mail className="mr-2 h-4 w-4" />
Email
</TabsTrigger>
<TabsTrigger value="security">
<Lock className="mr-2 h-4 w-4" />
Sécurité
Mot de passe
</TabsTrigger>
</TabsList>
@ -318,43 +346,14 @@ export default function Profile() {
</CardHeader>
<CardContent>
<form onSubmit={handleProfileSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="firstName">Prénom</Label>
<Label htmlFor="name">Nom</Label>
<Input
id="firstName"
name="firstName"
value={profileForm.firstName}
id="name"
name="name"
value={profileForm.name}
onChange={handleProfileChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Nom</Label>
<Input
id="lastName"
name="lastName"
value={profileForm.lastName}
onChange={handleProfileChange}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="username">Nom d'utilisateur</Label>
<Input
id="username"
name="username"
value={profileForm.username}
onChange={handleProfileChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio</Label>
<textarea
id="bio"
name="bio"
value={profileForm.bio}
onChange={handleProfileChange}
className="w-full min-h-[100px] rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
required
/>
</div>
<Button type="submit" disabled={saving}>
@ -375,6 +374,56 @@ export default function Profile() {
</Card>
</TabsContent>
<TabsContent value="email" className="mt-6">
<Card>
<CardHeader>
<CardTitle>Changer d'email</CardTitle>
<CardDescription>
Mettez à jour votre adresse email
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleEmailSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="newEmail">Nouvel email</Label>
<Input
id="newEmail"
name="newEmail"
type="email"
value={emailForm.newEmail}
onChange={handleEmailChange}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="emailPassword">Mot de passe actuel</Label>
<Input
id="emailPassword"
name="password"
type="password"
value={emailForm.password}
onChange={handleEmailChange}
required
/>
</div>
<Button type="submit" disabled={saving}>
{saving ? (
<>
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent"></div>
Enregistrement...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Changer l'email
</>
)}
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="security" className="mt-6">
<Card>
<CardHeader>
@ -447,7 +496,7 @@ export default function Profile() {
<p className="text-muted-foreground">Vous n'avez pas encore créé de recettes</p>
<Button
className="mt-4"
onClick={() => navigate("/recipes/create")}
onClick={() => navigate("/recipes/new")}
>
Créer une recette
</Button>
@ -481,63 +530,10 @@ export default function Profile() {
{userRecipes.length > 3 && (
<Button
variant="outline"
className="mt-4"
onClick={() => navigate("/my-recipes")}
>
Voir toutes mes recettes ({userRecipes.length})
</Button>
)}
</div>
)}
</div>
<div>
<h2 className="text-2xl font-bold">Mes favoris</h2>
<Separator className="my-4" />
{favoriteRecipes.length === 0 ? (
<div className="rounded-lg border p-6 text-center">
<p className="text-muted-foreground">Vous n'avez pas encore de recettes favorites</p>
<Button
className="mt-4"
onClick={() => navigate("/recipes")}
>
Explorer les recettes
</Button>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{favoriteRecipes.slice(0, 3).map(recipe => (
<Card key={recipe.id} className="overflow-hidden">
<div className="aspect-video w-full overflow-hidden">
<img
src={recipe.imageUrl || "/images/recipe-placeholder.jpg"}
alt={recipe.title}
className="h-full w-full object-cover"
/>
</div>
<CardHeader className="p-4">
<CardTitle className="text-lg">{recipe.title}</CardTitle>
</CardHeader>
<CardFooter className="p-4 pt-0">
<Button
variant="outline"
className="w-full"
onClick={() => navigate(`/recipes/${recipe.id}`)}
>
Voir la recette
</Button>
</CardFooter>
</Card>
))}
{favoriteRecipes.length > 3 && (
<Button
variant="outline"
className="mt-4"
onClick={() => navigate("/favorites")}
>
Voir tous mes favoris ({favoriteRecipes.length})
Voir toutes mes recettes ({userRecipes.length})
</Button>
)}
</div>