change mail + change password ok !
This commit is contained in:
parent
223b593495
commit
7c2374f0b3
208
backend/package-lock.json
generated
208
backend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "resetToken" TEXT;
|
||||
ALTER TABLE "User" ADD COLUMN "resetTokenExpiry" DATETIME;
|
||||
@ -8,16 +8,18 @@ datasource db {
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
password String? // Optionnel pour les utilisateurs Google
|
||||
name String
|
||||
googleId String? @unique // ID Google pour SSO
|
||||
stripeId String @unique
|
||||
subscription String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
recipes Recipe[]
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
password String? // Optionnel pour les utilisateurs Google
|
||||
name String
|
||||
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[]
|
||||
}
|
||||
|
||||
model Recipe {
|
||||
|
||||
298
backend/src/routes/users.js
Normal file
298
backend/src/routes/users.js
Normal 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' });
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -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();
|
||||
|
||||
29
backend/src/utils/email.js
Normal file
29
backend/src/utils/email.js
Normal 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
31
frontend/src/api/user.ts
Normal 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;
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
<Input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
value={profileForm.firstName}
|
||||
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>
|
||||
<Label htmlFor="name">Nom</Label>
|
||||
<Input
|
||||
id="username"
|
||||
name="username"
|
||||
value={profileForm.username}
|
||||
id="name"
|
||||
name="name"
|
||||
value={profileForm.name}
|
||||
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>
|
||||
@ -482,7 +531,7 @@ export default function Profile() {
|
||||
<Button
|
||||
variant="outline"
|
||||
className="mt-4"
|
||||
onClick={() => navigate("/my-recipes")}
|
||||
onClick={() => navigate("/recipes")}
|
||||
>
|
||||
Voir toutes mes recettes ({userRecipes.length})
|
||||
</Button>
|
||||
@ -490,59 +539,6 @@ export default function Profile() {
|
||||
</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})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user