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"
|
||||
MAIL_HOST="smtp.gmail.com"
|
||||
MAIL_PORT="587"
|
||||
MAIL_USER="your_email@gmail.com"
|
||||
MAIL_PASS="your_app_password"
|
||||
MAIL_FROM="Thé Tip Top <your_email@gmail.com>"
|
||||
MAIL_USER=abayomiandil62@gmail.com
|
||||
MAIL_PASS=dumqamoupdrliygf
|
||||
MAIL_FROM="Thé Tip Top <abayomiandil62@gmail.com>"
|
||||
|
||||
# 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.
|
||||
|
||||
@@ -1,6 +1,52 @@
|
||||
const passport = require("passport");
|
||||
const GoogleStrategy = require("passport-google-oauth20").Strategy;
|
||||
const crypto = require("crypto");
|
||||
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,
|
||||
// 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);
|
||||
} catch (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({
|
||||
from: process.env.MAIL_FROM,
|
||||
@@ -283,7 +286,7 @@ const forgotPassword = async (req, res) => {
|
||||
const resetPassword = async (req, res) => {
|
||||
try {
|
||||
const { token } = req.params;
|
||||
const { password } = req.body;
|
||||
const { password, confirmPassword } = req.body;
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
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({
|
||||
where: {
|
||||
resetPasswordToken: token,
|
||||
|
||||
@@ -23,6 +23,8 @@ const authMiddleware = async (req, res, next) => {
|
||||
lastname: true,
|
||||
email: 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();
|
||||
} catch (error) {
|
||||
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 Inscription from './pages/Inscription'
|
||||
import AuthCallback from './pages/AuthCallback'
|
||||
import DefinirMotDePasse from './pages/DefinirMotDePasse'
|
||||
import Accueil from './pages/Accueil'
|
||||
import ReglesJeu from './pages/ReglesJeu'
|
||||
import MesGains from './pages/MesGains'
|
||||
@@ -31,6 +32,7 @@ export default function App() {
|
||||
<Route path="/connexion" element={<Connexion />} />
|
||||
<Route path="/inscription" element={<Inscription />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/definir-mot-de-passe/:token" element={<DefinirMotDePasse />} />
|
||||
|
||||
{/* Pages publiques */}
|
||||
<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 UserNav from '../components/UserNav'
|
||||
import { changePasswordApi, deleteAccountApi } from '../services/auth'
|
||||
import { changePasswordApi, deleteAccountApi, meApi, forgotPasswordApi } from '../services/auth'
|
||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
@@ -22,6 +22,35 @@ export default function Profile() {
|
||||
const [pwError, setPwError] = useState('')
|
||||
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 handlePwSubmit = async (e) => {
|
||||
@@ -97,9 +126,37 @@ export default function Profile() {
|
||||
|
||||
{/* Section changement de mot de passe */}
|
||||
<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">
|
||||
<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" />
|
||||
@@ -108,12 +165,13 @@ export default function Profile() {
|
||||
</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">
|
||||
{pwError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasPassword !== false && (
|
||||
<form onSubmit={handlePwSubmit} className="space-y-4">
|
||||
{[
|
||||
{ name: 'currentPassword', label: 'Mot de passe actuel', key: 'current' },
|
||||
@@ -153,6 +211,7 @@ export default function Profile() {
|
||||
{pwLoading ? 'Modification…' : 'Modifier le mot de passe'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -57,6 +57,38 @@ export async function verifyCodeApi({ email, code }) {
|
||||
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() {
|
||||
const token = localStorage.getItem('token')
|
||||
const res = await fetch(`${BASE}/account`, {
|
||||
|
||||
Reference in New Issue
Block a user