Connexion via Google (OAuth 2.0)

Ajoute l'authentification Google en complément de la connexion par mot de
passe. Le callback OAuth émet le même JWT que la connexion classique, donc
le middleware d'authentification et ProtectedRoute restent inchangés.

Backend
- stratégie passport-google-oauth20 dans src/config/passport.js
- routes GET /api/auth/google et /google/callback, documentées dans Swagger
- un compte existant avec le même email est rattaché automatiquement au
  compte Google, et son email passe vérifié
- les routes répondent 503 si les identifiants ne sont pas configurés,
  plutôt que de faire échouer le démarrage du serveur
- login et changePassword ne comparent plus un hash inexistant sur les
  comptes sans mot de passe

Base de données
- password devient nullable
- colonnes provider, providerId et avatarUrl, index unique sur le couple
  provider/providerId

Frontend
- page /auth/callback qui récupère le token et redirige selon le rôle
- boutons Google de Connexion et Inscription branchés
- boutons Facebook désactivés en attendant les identifiants Meta

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Emma781227
2026-08-17 00:23:18 +02:00
parent 20c865a071
commit 464d666343
13 changed files with 418 additions and 7 deletions

View File

@@ -11,3 +11,11 @@ MAIL_PORT="587"
MAIL_USER="your_email@gmail.com" MAIL_USER="your_email@gmail.com"
MAIL_PASS="your_app_password" MAIL_PASS="your_app_password"
MAIL_FROM="Thé Tip Top <your_email@gmail.com>" MAIL_FROM="Thé Tip Top <your_email@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.
# URI de redirection à déclarer côté provider : APP_URL + /api/auth/google/callback
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
FACEBOOK_APP_ID=""
FACEBOOK_APP_SECRET=""

View File

@@ -17,6 +17,8 @@
"helmet": "^8.3.0", "helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"prisma": "^6.19.3", "prisma": "^6.19.3",
"swagger-jsdoc": "^6.3.0", "swagger-jsdoc": "^6.3.0",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",
@@ -2174,6 +2176,15 @@
"node": "18 || 20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/base64url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.40", "version": "2.10.40",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
@@ -5598,6 +5609,12 @@
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/oauth": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
"integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
"license": "MIT"
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5758,6 +5775,64 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/passport": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT",
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
"utils-merge": "^1.0.1"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-google-oauth20": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
"integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==",
"license": "MIT",
"dependencies": {
"passport-oauth2": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-oauth2": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
"integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==",
"license": "MIT",
"dependencies": {
"base64url": "3.x.x",
"oauth": "0.10.x",
"passport-strategy": "1.x.x",
"uid2": "0.0.x",
"utils-merge": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -5819,6 +5894,11 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pause": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/perfect-debounce": { "node_modules/perfect-debounce": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@@ -6765,6 +6845,12 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/uid2": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
"license": "MIT"
},
"node_modules/undefsafe": { "node_modules/undefsafe": {
"version": "2.0.5", "version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -6857,6 +6943,15 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/v8-to-istanbul": { "node_modules/v8-to-istanbul": {
"version": "9.3.0", "version": "9.3.0",
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",

View File

@@ -23,6 +23,8 @@
"helmet": "^8.3.0", "helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"nodemailer": "^9.0.3", "nodemailer": "^9.0.3",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"prisma": "^6.19.3", "prisma": "^6.19.3",
"swagger-jsdoc": "^6.3.0", "swagger-jsdoc": "^6.3.0",
"swagger-ui-express": "^5.0.1", "swagger-ui-express": "^5.0.1",

View File

@@ -0,0 +1,11 @@
-- CreateEnum
CREATE TYPE "AuthProvider" AS ENUM ('LOCAL', 'GOOGLE', 'FACEBOOK');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "avatarUrl" TEXT,
ADD COLUMN "provider" "AuthProvider" NOT NULL DEFAULT 'LOCAL',
ADD COLUMN "providerId" TEXT,
ALTER COLUMN "password" DROP NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "User_provider_providerId_key" ON "User"("provider", "providerId");

View File

@@ -24,12 +24,20 @@ enum ClaimStatus {
CANCELLED CANCELLED
} }
enum AuthProvider {
LOCAL
GOOGLE
FACEBOOK
}
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
firstname String firstname String
lastname String lastname String
email String @unique email String @unique
password String // Nul pour les comptes créés via un provider externe (Google, Facebook) :
// ces utilisateurs n'ont jamais choisi de mot de passe chez nous.
password String?
role Role @default(CLIENT) role Role @default(CLIENT)
birthdate DateTime? birthdate DateTime?
gender String? gender String?
@@ -43,10 +51,16 @@ model User {
resetPasswordToken String? resetPasswordToken String?
resetPasswordExpires DateTime? resetPasswordExpires DateTime?
provider AuthProvider @default(LOCAL)
providerId String?
avatarUrl String?
tickets Ticket[] tickets Ticket[]
participations Participation[] participations Participation[]
grandPrizeWinner GrandPrizeWinner? grandPrizeWinner GrandPrizeWinner?
claimsValidated Claim[] @relation("EmployeeClaims") claimsValidated Claim[] @relation("EmployeeClaims")
@@unique([provider, providerId])
} }
model Lot { model Lot {

View File

@@ -11,6 +11,7 @@ const adminRoutes = require("./routes/admin.routes");
const swaggerUi = require("swagger-ui-express"); const swaggerUi = require("swagger-ui-express");
const swaggerSpec = require("./docs/swagger"); const swaggerSpec = require("./docs/swagger");
const { passport } = require("./config/passport");
const app = express(); const app = express();
@@ -25,6 +26,8 @@ app.use((req, res, next) => {
}); });
app.use(cors({ origin: allowedOrigins })); app.use(cors({ origin: allowedOrigins }));
app.use(express.json()); app.use(express.json());
// OAuth stateless : initialize() suffit, aucune session n'est ouverte.
app.use(passport.initialize());
// Anti-bruteforce sur les routes d'authentification uniquement : 20 // Anti-bruteforce sur les routes d'authentification uniquement : 20
// tentatives par IP toutes les 15 minutes, sans limiter le reste de l'API // tentatives par IP toutes les 15 minutes, sans limiter le reste de l'API

View File

@@ -0,0 +1,82 @@
const passport = require("passport");
const GoogleStrategy = require("passport-google-oauth20").Strategy;
const prisma = require("./prisma");
// Authentification stateless : le callback OAuth émet directement notre JWT,
// donc pas de session Express ni de serialize/deserialize à brancher.
const googleEnabled =
Boolean(process.env.GOOGLE_CLIENT_ID) && Boolean(process.env.GOOGLE_CLIENT_SECRET);
if (googleEnabled) {
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: `${process.env.APP_URL || "http://localhost:5000"}/api/auth/google/callback`,
scope: ["profile", "email"],
},
async (accessToken, refreshToken, profile, done) => {
try {
const email = profile.emails?.[0]?.value;
if (!email) {
return done(null, false, {
message: "Aucune adresse email n'est associée à ce compte Google.",
});
}
// 1. Compte déjà lié à ce compte Google : connexion directe.
const byProvider = await prisma.user.findUnique({
where: {
provider_providerId: { provider: "GOOGLE", providerId: profile.id },
},
});
if (byProvider) return done(null, byProvider);
// 2. Un compte existe avec le même email : on le rattache à Google.
// L'email étant déjà validé par Google, on en profite pour lever
// la vérification si elle était en attente.
const byEmail = await prisma.user.findUnique({ where: { email } });
if (byEmail) {
const linked = await prisma.user.update({
where: { id: byEmail.id },
data: {
provider: "GOOGLE",
providerId: profile.id,
isEmailVerified: true,
emailVerifyToken: null,
emailVerifyExpires: null,
avatarUrl: byEmail.avatarUrl || profile.photos?.[0]?.value || null,
},
});
return done(null, linked);
}
// 3. Première connexion : création du compte.
const created = await prisma.user.create({
data: {
firstname: profile.name?.givenName || profile.displayName || "Utilisateur",
lastname: profile.name?.familyName || "",
email,
password: null,
provider: "GOOGLE",
providerId: profile.id,
avatarUrl: profile.photos?.[0]?.value || null,
isEmailVerified: true,
},
});
return done(null, created);
} catch (error) {
return done(error);
}
}
)
);
}
module.exports = { passport, googleEnabled };

View File

@@ -156,6 +156,14 @@ const login = async (req, res) => {
}); });
} }
// Compte créé via un provider externe : aucun mot de passe à comparer.
if (!user.password) {
const providerLabel = user.provider === "FACEBOOK" ? "Facebook" : "Google";
return res.status(401).json({
message: `Ce compte utilise la connexion ${providerLabel}. Utilisez le bouton « ${providerLabel} » pour vous connecter.`,
});
}
const isPasswordValid = await bcrypt.compare(password, user.password); const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) { if (!isPasswordValid) {
@@ -354,6 +362,50 @@ const verifyCode = async (req, res) => {
} }
}; };
// Fin du parcours OAuth : passport a posé l'utilisateur sur req.user, on émet
// le même JWT que la connexion classique et on renvoie le navigateur vers le
// front, qui récupère le token dans l'URL.
const oauthCallback = (req, res) => {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
try {
const user = req.user;
if (!user) {
return res.redirect(
`${frontendUrl}/auth/callback?error=${encodeURIComponent(
"Connexion externe refusée."
)}`
);
}
const token = jwt.sign(
{ id: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "24h" }
);
const payload = Buffer.from(
JSON.stringify({
id: user.id,
firstname: user.firstname,
lastname: user.lastname,
email: user.email,
role: user.role,
})
).toString("base64");
return res.redirect(`${frontendUrl}/auth/callback?token=${token}&user=${payload}`);
} catch (error) {
console.error("Erreur oauthCallback:", error);
return res.redirect(
`${frontendUrl}/auth/callback?error=${encodeURIComponent(
"Erreur serveur lors de la connexion externe."
)}`
);
}
};
const me = async (req, res) => { const me = async (req, res) => {
return res.json({ return res.json({
user: req.user, user: req.user,
@@ -415,6 +467,13 @@ const changePassword = async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: userId } }); const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user.password) {
return res.status(400).json({
message:
"Ce compte utilise une connexion externe et n'a pas de mot de passe à modifier.",
});
}
const isValid = await bcrypt.compare(currentPassword, user.password); const isValid = await bcrypt.compare(currentPassword, user.password);
if (!isValid) { if (!isValid) {
return res.status(401).json({ message: "Mot de passe actuel incorrect." }); return res.status(401).json({ message: "Mot de passe actuel incorrect." });
@@ -440,4 +499,5 @@ module.exports = {
forgotPassword, forgotPassword,
changePassword, changePassword,
deleteAccount, deleteAccount,
oauthCallback,
}; };

View File

@@ -1,8 +1,9 @@
const express = require("express"); const express = require("express");
const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount } = require("../controllers/auth.controller"); const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller");
const authMiddleware = require("../middlewares/auth.middleware"); const authMiddleware = require("../middlewares/auth.middleware");
const validate = require("../middlewares/validate.middleware"); const validate = require("../middlewares/validate.middleware");
const { registerSchema, loginSchema } = require("../validations/auth.validation"); const { registerSchema, loginSchema } = require("../validations/auth.validation");
const { passport, googleEnabled } = require("../config/passport");
const router = express.Router(); const router = express.Router();
@@ -161,4 +162,55 @@ router.post("/verify-code", verifyCode);
router.patch("/change-password", authMiddleware, changePassword); router.patch("/change-password", authMiddleware, changePassword);
router.delete("/account", authMiddleware, deleteAccount); router.delete("/account", authMiddleware, deleteAccount);
/**
* @swagger
* /api/auth/google:
* get:
* summary: Démarre la connexion via Google (redirection OAuth 2.0)
* tags:
* - Auth
* responses:
* 302:
* description: Redirection vers l'écran de consentement Google.
* 503:
* description: Connexion Google non configurée sur ce serveur.
*/
/**
* @swagger
* /api/auth/google/callback:
* get:
* summary: Retour de Google, émet le JWT et redirige vers le front
* tags:
* - Auth
* responses:
* 302:
* description: Redirection vers FRONTEND_URL/auth/callback avec le token.
*/
if (googleEnabled) {
router.get(
"/google",
passport.authenticate("google", { scope: ["profile", "email"], session: false })
);
router.get(
"/google/callback",
passport.authenticate("google", {
session: false,
failureRedirect: `${process.env.FRONTEND_URL || "http://localhost:5173"}/auth/callback?error=${encodeURIComponent(
"Connexion Google refusée."
)}`,
}),
oauthCallback
);
} else {
// Sans identifiants configurés, on répond explicitement plutôt que de
// laisser passport planter au démarrage.
const notConfigured = (req, res) =>
res.status(503).json({ message: "La connexion Google n'est pas configurée sur ce serveur." });
router.get("/google", notConfigured);
router.get("/google/callback", notConfigured);
}
module.exports = router; module.exports = router;

View File

@@ -5,6 +5,7 @@ import ProtectedRoute from './components/ProtectedRoute.jsx'
import CookieBanner from './components/CookieBanner.jsx' 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 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'
@@ -29,6 +30,7 @@ export default function App() {
<Route path="/" element={<Navigate to="/accueil" replace />} /> <Route path="/" element={<Navigate to="/accueil" replace />} />
<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 />} />
{/* Pages publiques */} {/* Pages publiques */}
<Route path="/accueil" element={<Layout><Accueil /></Layout>} /> <Route path="/accueil" element={<Layout><Accueil /></Layout>} />

View File

@@ -0,0 +1,68 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
// Point d'arrivée après un aller-retour OAuth : le backend nous renvoie ici
// avec le JWT et le profil encodés dans l'URL.
export default function AuthCallback() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { login } = useAuth()
const [error, setError] = useState('')
// StrictMode monte deux fois en dev : on ne traite le token qu'une seule fois.
const handled = useRef(false)
useEffect(() => {
if (handled.current) return
handled.current = true
const errorParam = searchParams.get('error')
if (errorParam) {
setError(errorParam)
return
}
const token = searchParams.get('token')
const encodedUser = searchParams.get('user')
if (!token || !encodedUser) {
setError('Réponse de connexion incomplète.')
return
}
try {
const user = JSON.parse(atob(encodedUser))
login(user, token)
if (user.role === 'ADMIN') navigate('/admin', { replace: true })
else if (user.role === 'EMPLOYEE') navigate('/employe', { replace: true })
else navigate('/mes-gains', { replace: true })
} catch {
setError('Réponse de connexion illisible.')
}
}, [searchParams, login, navigate])
if (error) {
return (
<div className="min-h-screen bg-white flex flex-col justify-center items-center px-8 max-w-sm mx-auto text-center">
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
{error}
</div>
<Link
to="/connexion"
className="text-sm text-[#006B6B] font-medium hover:underline"
>
Retour à la connexion
</Link>
</div>
)
}
return (
<div className="min-h-screen bg-white flex flex-col justify-center items-center px-8">
<p className="text-gray-500 text-sm" role="status">
Connexion en cours…
</p>
</div>
)
}

View File

@@ -15,6 +15,12 @@ export default function Connexion() {
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value }) const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
// Navigation pleine page (et non fetch) : le flux OAuth passe par des
// redirections vers Google puis retour sur /auth/callback.
const handleGoogleLogin = () => {
window.location.href = '/api/auth/google'
}
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
setError('') setError('')
@@ -123,6 +129,7 @@ export default function Connexion() {
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<button <button
type="button" type="button"
onClick={handleGoogleLogin}
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
> >
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500"> <div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">
@@ -132,9 +139,11 @@ export default function Connexion() {
</button> </button>
<button <button
type="button" type="button"
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors" disabled
title="Connexion Facebook bientôt disponible"
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-400 cursor-not-allowed opacity-60"
> >
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500"> <div className="w-5 h-5 rounded-full border border-gray-300 flex items-center justify-center text-[10px] font-bold text-gray-400">
f f
</div> </div>
Facebook Facebook

View File

@@ -24,6 +24,11 @@ export default function Inscription() {
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value }) const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
// Même route que la connexion : Google crée le compte à la première venue.
const handleGoogleSignup = () => {
window.location.href = '/api/auth/google'
}
// Étape 1 — Inscription // Étape 1 — Inscription
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault() e.preventDefault()
@@ -214,12 +219,12 @@ export default function Inscription() {
</div> </div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<button type="button" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"> <button type="button" onClick={handleGoogleSignup} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">G</div> <div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">G</div>
Google Google
</button> </button>
<button type="button" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"> <button type="button" disabled title="Inscription Facebook bientôt disponible" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-400 cursor-not-allowed opacity-60">
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">f</div> <div className="w-5 h-5 rounded-full border border-gray-300 flex items-center justify-center text-[10px] font-bold text-gray-400">f</div>
Facebook Facebook
</button> </button>
</div> </div>