refont de la connexion par google et validation de lenvoie de mail a l'utilisater
This commit is contained in:
@@ -8,9 +8,9 @@ APP_URL="http://localhost:5000"
|
|||||||
FRONTEND_URL="http://localhost:5173"
|
FRONTEND_URL="http://localhost:5173"
|
||||||
MAIL_HOST="smtp.gmail.com"
|
MAIL_HOST="smtp.gmail.com"
|
||||||
MAIL_PORT="587"
|
MAIL_PORT="587"
|
||||||
MAIL_USER="your_email@gmail.com"
|
MAIL_USER=abayomiandil62@gmail.com
|
||||||
MAIL_PASS="your_app_password"
|
MAIL_PASS=dumqamoupdrliygf
|
||||||
MAIL_FROM="Thé Tip Top <your_email@gmail.com>"
|
MAIL_FROM="Thé Tip Top <abayomiandil62@gmail.com>"
|
||||||
|
|
||||||
# Connexion externe (OAuth 2.0). Laisser vide désactive proprement le bouton
|
# Connexion externe (OAuth 2.0). Laisser vide désactive proprement le bouton
|
||||||
# correspondant : la route répond 503 au lieu de faire planter le serveur.
|
# correspondant : la route répond 503 au lieu de faire planter le serveur.
|
||||||
|
|||||||
@@ -1,6 +1,52 @@
|
|||||||
const passport = require("passport");
|
const passport = require("passport");
|
||||||
const GoogleStrategy = require("passport-google-oauth20").Strategy;
|
const GoogleStrategy = require("passport-google-oauth20").Strategy;
|
||||||
|
const crypto = require("crypto");
|
||||||
const prisma = require("./prisma");
|
const prisma = require("./prisma");
|
||||||
|
const transporter = require("./mailer");
|
||||||
|
|
||||||
|
// Un compte créé via un provider externe n'a pas de mot de passe. On lui en
|
||||||
|
// propose un dès l'inscription, via le même mécanisme de jeton que la
|
||||||
|
// réinitialisation : sans mot de passe, l'utilisateur dépend entièrement de
|
||||||
|
// Google pour se connecter, et le formulaire de changement de son profil lui
|
||||||
|
// reste inaccessible.
|
||||||
|
const PASSWORD_SETUP_EXPIRY_MS = 1000 * 60 * 60 * 24; // 24 h
|
||||||
|
|
||||||
|
async function sendPasswordSetupEmail(user) {
|
||||||
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
resetPasswordToken: token,
|
||||||
|
resetPasswordExpires: new Date(Date.now() + PASSWORD_SETUP_EXPIRY_MS),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||||
|
const setupUrl = `${frontendUrl}/definir-mot-de-passe/${token}`;
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.MAIL_FROM,
|
||||||
|
to: user.email,
|
||||||
|
subject: "Définissez votre mot de passe Thé Tip Top",
|
||||||
|
html: `
|
||||||
|
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||||
|
<h2 style="color:#006B6B">Bienvenue ${user.firstname} !</h2>
|
||||||
|
<p>Votre compte a été créé avec Google. Vous pouvez continuer à vous
|
||||||
|
connecter de cette façon, ou définir un mot de passe pour accéder
|
||||||
|
également à votre compte sans passer par Google.</p>
|
||||||
|
<p style="text-align:center;padding:20px 0">
|
||||||
|
<a href="${setupUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
|
||||||
|
Définir mon mot de passe
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#888;font-size:13px">Ce lien expire dans 24 heures. Si vous
|
||||||
|
ne souhaitez pas de mot de passe, ignorez simplement cet email : votre
|
||||||
|
connexion Google continuera de fonctionner.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Authentification stateless : le callback OAuth émet directement notre JWT,
|
// Authentification stateless : le callback OAuth émet directement notre JWT,
|
||||||
// donc pas de session Express ni de serialize/deserialize à brancher.
|
// donc pas de session Express ni de serialize/deserialize à brancher.
|
||||||
@@ -70,6 +116,14 @@ if (googleEnabled) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Un envoi qui échoue ne doit pas bloquer la connexion : le compte
|
||||||
|
// est valide, et « mot de passe oublié » permet de rattraper.
|
||||||
|
try {
|
||||||
|
await sendPasswordSetupEmail(created);
|
||||||
|
} catch (mailError) {
|
||||||
|
console.error("Erreur envoi email de definition du mot de passe:", mailError);
|
||||||
|
}
|
||||||
|
|
||||||
return done(null, created);
|
return done(null, created);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return done(error);
|
return done(error);
|
||||||
|
|||||||
@@ -252,7 +252,10 @@ const forgotPassword = async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetUrl = `${process.env.APP_URL}/api/auth/reset-password/${resetPasswordToken}`;
|
// Le lien doit ouvrir le formulaire du front, pas la route POST de l'API :
|
||||||
|
// un clic depuis un email est une requête GET, qui tombait en 404.
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||||
|
const resetUrl = `${frontendUrl}/definir-mot-de-passe/${resetPasswordToken}`;
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: process.env.MAIL_FROM,
|
from: process.env.MAIL_FROM,
|
||||||
@@ -283,7 +286,7 @@ const forgotPassword = async (req, res) => {
|
|||||||
const resetPassword = async (req, res) => {
|
const resetPassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { token } = req.params;
|
const { token } = req.params;
|
||||||
const { password } = req.body;
|
const { password, confirmPassword } = req.body;
|
||||||
|
|
||||||
if (!password || password.length < 8) {
|
if (!password || password.length < 8) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -291,6 +294,14 @@ const resetPassword = async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// La confirmation est vérifiée côté serveur aussi : le contrôle du
|
||||||
|
// navigateur ne protège pas un appel direct à l'API.
|
||||||
|
if (confirmPassword !== undefined && password !== confirmPassword) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: "Les deux mots de passe ne correspondent pas.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findFirst({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
resetPasswordToken: token,
|
resetPasswordToken: token,
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ const authMiddleware = async (req, res, next) => {
|
|||||||
lastname: true,
|
lastname: true,
|
||||||
email: true,
|
email: true,
|
||||||
role: true,
|
role: true,
|
||||||
|
provider: true,
|
||||||
|
password: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -32,7 +34,11 @@ const authMiddleware = async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
req.user = user;
|
// req.user est renvoyé tel quel par /api/auth/me : on remplace le hash par
|
||||||
|
// un simple booléen, qui suffit au profil pour proposer « définir » ou
|
||||||
|
// « modifier » son mot de passe.
|
||||||
|
const { password, ...rest } = user;
|
||||||
|
req.user = { ...rest, hasPassword: Boolean(password) };
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
|
|||||||
142
backend/tests/googlePassword.test.js
Normal file
142
backend/tests/googlePassword.test.js
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
const request = require("supertest");
|
||||||
|
const app = require("../src/app");
|
||||||
|
const prisma = require("../src/config/prisma");
|
||||||
|
const mailer = require("../src/config/mailer");
|
||||||
|
const { passport, googleEnabled } = require("../src/config/passport");
|
||||||
|
|
||||||
|
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
|
||||||
|
|
||||||
|
const EMAIL = "google-pwd-test@test.local";
|
||||||
|
const PROFILE = {
|
||||||
|
id: "sim-google-id-123456",
|
||||||
|
displayName: "Chloe Martin",
|
||||||
|
name: { givenName: "Chloe", familyName: "Martin" },
|
||||||
|
emails: [{ value: EMAIL }],
|
||||||
|
photos: [{ value: "https://example.test/avatar.png" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function cleanup() {
|
||||||
|
await prisma.user.deleteMany({ where: { email: EMAIL } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rejoue le callback de la strategie Google sans sortir sur le reseau.
|
||||||
|
function verifierProfilGoogle() {
|
||||||
|
const strategy = passport._strategy("google");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
strategy._verify("access-token", "refresh-token", PROFILE, (err, user) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(user);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(cleanup);
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanup();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
const decrire = googleEnabled ? describe : describe.skip;
|
||||||
|
|
||||||
|
decrire("Inscription Google et definition du mot de passe", () => {
|
||||||
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
test("un compte Google est cree sans mot de passe et recoit un lien", async () => {
|
||||||
|
mailer.sendMail.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const user = await verifierProfilGoogle();
|
||||||
|
|
||||||
|
expect(user.provider).toBe("GOOGLE");
|
||||||
|
expect(user.password).toBeNull();
|
||||||
|
expect(user.isEmailVerified).toBe(true);
|
||||||
|
|
||||||
|
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||||
|
const envoi = mailer.sendMail.mock.calls[0][0];
|
||||||
|
expect(envoi.to).toBe(EMAIL);
|
||||||
|
expect(envoi.subject).toMatch(/mot de passe/i);
|
||||||
|
|
||||||
|
// Le lien doit viser le formulaire du front, pas la route POST de l API.
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
||||||
|
expect(envoi.html).toContain(`${frontendUrl}/definir-mot-de-passe/`);
|
||||||
|
|
||||||
|
const enBase = await prisma.user.findUnique({ where: { email: EMAIL } });
|
||||||
|
expect(enBase.resetPasswordToken).toHaveLength(64);
|
||||||
|
expect(enBase.resetPasswordExpires.getTime()).toBeGreaterThan(Date.now());
|
||||||
|
ctxToken = enBase.resetPasswordToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le profil signale que le compte n a pas de mot de passe", async () => {
|
||||||
|
const jwt = require("jsonwebtoken");
|
||||||
|
const u = await prisma.user.findUnique({ where: { email: EMAIL } });
|
||||||
|
const token = jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||||
|
|
||||||
|
const res = await request(app).get("/api/auth/me").set("Authorization", "Bearer " + token).expect(200);
|
||||||
|
|
||||||
|
expect(res.body.user.hasPassword).toBe(false);
|
||||||
|
expect(res.body.user.provider).toBe("GOOGLE");
|
||||||
|
// Le hash ne doit jamais sortir de l API.
|
||||||
|
expect(res.body.user.password).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("la connexion par mot de passe est refusee tant qu il n en existe pas", async () => {
|
||||||
|
const res = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "PeuImporte123" });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
expect(res.body.message).toMatch(/connexion Google/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une confirmation qui ne correspond pas est refusee", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/auth/reset-password/" + ctxToken)
|
||||||
|
.send({ password: "MonMotDePasse1", confirmPassword: "AutreMotDePasse2" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.message).toMatch(/ne correspondent pas/);
|
||||||
|
|
||||||
|
const u = await prisma.user.findUnique({ where: { email: EMAIL } });
|
||||||
|
expect(u.password).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("un mot de passe trop court est refuse", async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post("/api/auth/reset-password/" + ctxToken)
|
||||||
|
.send({ password: "court", confirmPassword: "court" });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.message).toMatch(/8 caractères/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("le mot de passe defini permet ensuite de se connecter et de le changer", async () => {
|
||||||
|
const ok = await request(app)
|
||||||
|
.post("/api/auth/reset-password/" + ctxToken)
|
||||||
|
.send({ password: "MonMotDePasse1", confirmPassword: "MonMotDePasse1" })
|
||||||
|
.expect(200);
|
||||||
|
expect(ok.body.message).toMatch(/réinitialisé/);
|
||||||
|
|
||||||
|
const login = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "MonMotDePasse1" }).expect(200);
|
||||||
|
expect(login.body.token).toBeDefined();
|
||||||
|
|
||||||
|
// Le jeton a bien ete consomme.
|
||||||
|
const u = await prisma.user.findUnique({ where: { email: EMAIL } });
|
||||||
|
expect(u.resetPasswordToken).toBeNull();
|
||||||
|
|
||||||
|
const me = await request(app).get("/api/auth/me").set("Authorization", "Bearer " + login.body.token).expect(200);
|
||||||
|
expect(me.body.user.hasPassword).toBe(true);
|
||||||
|
|
||||||
|
const change = await request(app)
|
||||||
|
.patch("/api/auth/change-password")
|
||||||
|
.set("Authorization", "Bearer " + login.body.token)
|
||||||
|
.send({ currentPassword: "MonMotDePasse1", newPassword: "EncoreUnAutre2" });
|
||||||
|
expect(change.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("une seconde connexion Google ne renvoie pas de nouveau lien", async () => {
|
||||||
|
mailer.sendMail.mockResolvedValue(true);
|
||||||
|
|
||||||
|
const user = await verifierProfilGoogle();
|
||||||
|
|
||||||
|
expect(user.email).toBe(EMAIL);
|
||||||
|
expect(mailer.sendMail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let ctxToken;
|
||||||
@@ -6,6 +6,7 @@ import CookieBanner from './components/CookieBanner.jsx'
|
|||||||
import Connexion from './pages/Connexion'
|
import Connexion from './pages/Connexion'
|
||||||
import Inscription from './pages/Inscription'
|
import Inscription from './pages/Inscription'
|
||||||
import AuthCallback from './pages/AuthCallback'
|
import AuthCallback from './pages/AuthCallback'
|
||||||
|
import DefinirMotDePasse from './pages/DefinirMotDePasse'
|
||||||
import Accueil from './pages/Accueil'
|
import Accueil from './pages/Accueil'
|
||||||
import ReglesJeu from './pages/ReglesJeu'
|
import ReglesJeu from './pages/ReglesJeu'
|
||||||
import MesGains from './pages/MesGains'
|
import MesGains from './pages/MesGains'
|
||||||
@@ -31,6 +32,7 @@ export default function App() {
|
|||||||
<Route path="/connexion" element={<Connexion />} />
|
<Route path="/connexion" element={<Connexion />} />
|
||||||
<Route path="/inscription" element={<Inscription />} />
|
<Route path="/inscription" element={<Inscription />} />
|
||||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
|
<Route path="/definir-mot-de-passe/:token" element={<DefinirMotDePasse />} />
|
||||||
|
|
||||||
{/* Pages publiques */}
|
{/* Pages publiques */}
|
||||||
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />
|
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />
|
||||||
|
|||||||
133
frontend/src/pages/DefinirMotDePasse.jsx
Normal file
133
frontend/src/pages/DefinirMotDePasse.jsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { resetPasswordApi } from '../services/auth'
|
||||||
|
|
||||||
|
// Sert les deux parcours qui passent par un jeton envoyé par email :
|
||||||
|
// la définition initiale du mot de passe après une inscription Google,
|
||||||
|
// et la réinitialisation classique depuis « mot de passe oublié ».
|
||||||
|
export default function DefinirMotDePasse() {
|
||||||
|
const { token } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const [form, setForm] = useState({ password: '', confirmPassword: '' })
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [success, setSuccess] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (form.password.length < 8) {
|
||||||
|
setError('Le mot de passe doit contenir au moins 8 caractères.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.password !== form.confirmPassword) {
|
||||||
|
setError('Les deux mots de passe ne correspondent pas.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await resetPasswordApi({ token, ...form })
|
||||||
|
setSuccess('Mot de passe enregistré. Vous pouvez maintenant vous connecter avec votre email.')
|
||||||
|
setTimeout(() => navigate('/connexion'), 2500)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto text-center">
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm">
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
<Link to="/connexion" className="text-sm text-[#006B6B] font-medium hover:underline">
|
||||||
|
Aller à la connexion
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Mot de passe</h1>
|
||||||
|
<p className="text-gray-500 mt-1 text-sm">
|
||||||
|
Choisissez un mot de passe pour accéder à votre compte sans passer par Google.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
|
Nouveau mot de passe <span className="text-gray-800">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="password"
|
||||||
|
name="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
placeholder="8 caractères minimum"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
|
Confirmer le mot de passe <span className="text-gray-800">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="confirmPassword"
|
||||||
|
name="confirmPassword"
|
||||||
|
value={form.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
placeholder="Saisissez-le à nouveau"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showPassword}
|
||||||
|
onChange={(e) => setShowPassword(e.target.checked)}
|
||||||
|
className="w-4 h-4 accent-[#006B6B]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500">Afficher les mots de passe</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Enregistrement…' : 'Enregistrer mon mot de passe'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-8">
|
||||||
|
<Link to="/connexion" className="text-gray-700 font-medium hover:underline">
|
||||||
|
Retour à la connexion
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import UserNav from '../components/UserNav'
|
import UserNav from '../components/UserNav'
|
||||||
import { changePasswordApi, deleteAccountApi } from '../services/auth'
|
import { changePasswordApi, deleteAccountApi, meApi, forgotPasswordApi } from '../services/auth'
|
||||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
@@ -22,6 +22,35 @@ export default function Profile() {
|
|||||||
const [pwError, setPwError] = useState('')
|
const [pwError, setPwError] = useState('')
|
||||||
const [pwSuccess, setPwSuccess] = useState(false)
|
const [pwSuccess, setPwSuccess] = useState(false)
|
||||||
|
|
||||||
|
// Un compte créé via Google peut ne pas avoir de mot de passe : le
|
||||||
|
// formulaire de modification n'aurait alors aucun sens. L'information vient
|
||||||
|
// de l'API, car le profil stocké en localStorage ne la contient pas.
|
||||||
|
const [hasPassword, setHasPassword] = useState(null)
|
||||||
|
const [linkSending, setLinkSending] = useState(false)
|
||||||
|
const [linkSent, setLinkSent] = useState(false)
|
||||||
|
const [linkError, setLinkError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
meApi()
|
||||||
|
.then((data) => setHasPassword(data.user.hasPassword))
|
||||||
|
.catch((e) => {
|
||||||
|
if (!handleAuthError(e)) setHasPassword(true)
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSendSetupLink = async () => {
|
||||||
|
setLinkError('')
|
||||||
|
setLinkSending(true)
|
||||||
|
try {
|
||||||
|
await forgotPasswordApi(user.email)
|
||||||
|
setLinkSent(true)
|
||||||
|
} catch (e) {
|
||||||
|
if (!handleAuthError(e)) setLinkError(e.message)
|
||||||
|
} finally {
|
||||||
|
setLinkSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handlePwChange = (e) => setPwForm({ ...pwForm, [e.target.name]: e.target.value })
|
const handlePwChange = (e) => setPwForm({ ...pwForm, [e.target.name]: e.target.value })
|
||||||
|
|
||||||
const handlePwSubmit = async (e) => {
|
const handlePwSubmit = async (e) => {
|
||||||
@@ -97,9 +126,37 @@ export default function Profile() {
|
|||||||
|
|
||||||
{/* Section changement de mot de passe */}
|
{/* Section changement de mot de passe */}
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-sm font-semibold text-gray-800 mb-4">Changer mon mot de passe</h2>
|
<h2 className="text-sm font-semibold text-gray-800 mb-4">
|
||||||
|
{hasPassword === false ? 'Définir un mot de passe' : 'Changer mon mot de passe'}
|
||||||
|
</h2>
|
||||||
|
|
||||||
{pwSuccess && (
|
{hasPassword === false && (
|
||||||
|
<div className="px-4 py-4 rounded-lg bg-gray-50 border border-gray-200">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Votre compte a été créé avec Google et n'a pas encore de mot de passe.
|
||||||
|
Définissez-en un pour pouvoir aussi vous connecter avec votre email.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{linkSent ? (
|
||||||
|
<p className="mt-3 text-sm text-green-700">
|
||||||
|
Lien envoyé à {user.email}. Consultez votre boîte mail.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSendSetupLink}
|
||||||
|
disabled={linkSending}
|
||||||
|
className="mt-3 bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-medium px-4 py-2.5 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
{linkSending ? 'Envoi…' : 'Recevoir un lien par email'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{linkError && <p className="mt-2 text-sm text-red-600">{linkError}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasPassword !== false && pwSuccess && (
|
||||||
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm flex items-center gap-2">
|
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm flex items-center gap-2">
|
||||||
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
@@ -108,12 +165,13 @@ export default function Profile() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pwError && (
|
{hasPassword !== false && pwError && (
|
||||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||||
{pwError}
|
{pwError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasPassword !== false && (
|
||||||
<form onSubmit={handlePwSubmit} className="space-y-4">
|
<form onSubmit={handlePwSubmit} className="space-y-4">
|
||||||
{[
|
{[
|
||||||
{ name: 'currentPassword', label: 'Mot de passe actuel', key: 'current' },
|
{ name: 'currentPassword', label: 'Mot de passe actuel', key: 'current' },
|
||||||
@@ -153,6 +211,7 @@ export default function Profile() {
|
|||||||
{pwLoading ? 'Modification…' : 'Modifier le mot de passe'}
|
{pwLoading ? 'Modification…' : 'Modifier le mot de passe'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,6 +57,38 @@ export async function verifyCodeApi({ email, code }) {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function forgotPasswordApi(email) {
|
||||||
|
const res = await fetch(`${BASE}/forgot-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ email }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resetPasswordApi({ token, password, confirmPassword }) {
|
||||||
|
const res = await fetch(`${BASE}/reset-password/${token}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password, confirmPassword }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors de la définition du mot de passe.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function meApi() {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const res = await fetch(`${BASE}/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors de la récupération du profil.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export async function deleteAccountApi() {
|
export async function deleteAccountApi() {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const res = await fetch(`${BASE}/account`, {
|
const res = await fetch(`${BASE}/account`, {
|
||||||
|
|||||||
Reference in New Issue
Block a user