ajout de la connection via facebook et du captcha

This commit is contained in:
Emma781227
2026-09-14 21:08:04 +02:00
parent 28d743deb5
commit 8a6355cde1
29 changed files with 617 additions and 153 deletions

1
frontend/.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*

View File

@@ -11,6 +11,9 @@ COPY . .
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}
ARG VITE_TURNSTILE_SITE_KEY
ENV VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY}
RUN npm run build

View File

@@ -8,6 +8,7 @@
"name": "ttt-front",
"version": "0.0.0",
"dependencies": {
"@marsidev/react-turnstile": "^1.6.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.17.0"
@@ -121,6 +122,7 @@
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.7",
@@ -441,6 +443,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
},
@@ -489,33 +492,11 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"dev": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
@@ -789,6 +770,16 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@marsidev/react-turnstile": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.6.1.tgz",
"integrity": "sha512-ojI9NLYv821IRKi7KraTkxTto6KC/m58hNNyKxvYxm/cQBu5MI5eMkaiJs2JSQBFiXE3LPQI3seJVjv/2lgALA==",
"license": "MIT",
"peerDependencies": {
"react": "^17.0.2 || ^18.0.0 || ^19.0",
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0"
}
},
"node_modules/@oxc-project/types": {
"version": "0.144.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz",
@@ -1463,8 +1454,7 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
@@ -1511,6 +1501,7 @@
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1521,6 +1512,7 @@
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
"dev": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -1670,6 +1662,7 @@
"integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1710,7 +1703,6 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
@@ -1721,7 +1713,6 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -1815,6 +1806,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
@@ -1994,8 +1986,7 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/electron-to-chromium": {
"version": "1.5.408",
@@ -2067,6 +2058,7 @@
"integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==",
"dev": true,
"license": "MIT",
"peer": true,
"workspaces": [
"packages/*"
],
@@ -2943,7 +2935,6 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -3151,6 +3142,7 @@
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -3250,7 +3242,6 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -3275,6 +3266,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3284,6 +3276,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3296,8 +3289,7 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/react-router": {
"version": "7.18.2",
@@ -3692,6 +3684,7 @@
"integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.5",
@@ -4249,6 +4242,7 @@
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -13,6 +13,7 @@
"test:watch": "vitest"
},
"dependencies": {
"@marsidev/react-turnstile": "^1.6.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.17.0"

View File

@@ -0,0 +1,19 @@
import { Turnstile } from '@marsidev/react-turnstile'
import { CAPTCHA_ENABLED, TURNSTILE_SITE_KEY } from '../config/captcha'
// `ref` expose reset() : un jeton Turnstile n'est valable qu'une fois, il faut
// en redemander un après chaque envoi refusé.
export default function Captcha({ ref, onToken }) {
if (!CAPTCHA_ENABLED) return null
return (
<Turnstile
ref={ref}
siteKey={TURNSTILE_SITE_KEY}
options={{ language: 'fr', size: 'flexible' }}
onSuccess={onToken}
onExpire={() => onToken('')}
onError={() => onToken('')}
/>
)
}

View File

@@ -0,0 +1,8 @@
export const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY
// Sans clé (tests, dev sans .env), aucun widget n'est affiché et aucun jeton
// n'est exigé côté front ; le backend fait de même sans TURNSTILE_SECRET_KEY.
// navigator.webdriver : le pré-rendu Playwright (scripts/prerender.js) ne doit
// pas figer l'iframe Cloudflare dans le HTML statique.
export const CAPTCHA_ENABLED =
Boolean(TURNSTILE_SITE_KEY) && !(typeof navigator !== 'undefined' && navigator.webdriver)

View File

@@ -26,7 +26,10 @@ export default function AuthCallback() {
error = 'Réponse de connexion incomplète.'
} else {
try {
user = JSON.parse(atob(encodedUser))
// atob renvoie des octets : on les relit en UTF-8, sinon « Chloé »
// s'afficherait « Chloé ».
const bytes = Uint8Array.from(atob(encodedUser), (c) => c.charCodeAt(0))
user = JSON.parse(new TextDecoder().decode(bytes))
} catch {
error = 'Réponse de connexion illisible.'
}

View File

@@ -1,8 +1,10 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import { loginApi } from '../services/auth'
import { useAuth } from '../context/AuthContext'
import usePageMeta from '../hooks/usePageMeta'
import Captcha from '../components/Captcha'
import { CAPTCHA_ENABLED } from '../config/captcha'
export default function Connexion() {
usePageMeta({
@@ -18,14 +20,16 @@ export default function Connexion() {
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [captchaToken, setCaptchaToken] = useState('')
const captchaRef = useRef()
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 = () => {
// redirections vers Google/Facebook puis retour sur /auth/callback.
const handleOAuthLogin = (provider) => {
const apiUrl = import.meta.env.VITE_API_URL || ''
window.location.href = `${apiUrl}/api/auth/google`
window.location.href = `${apiUrl}/api/auth/${provider}`
}
const handleSubmit = async (e) => {
@@ -33,13 +37,16 @@ export default function Connexion() {
setError('')
setLoading(true)
try {
const { token, user } = await loginApi(form)
const { token, user } = await loginApi({ ...form, captchaToken })
login(user, token)
if (user.role === 'ADMIN') navigate('/admin')
else if (user.role === 'EMPLOYEE') navigate('/employe')
else navigate('/mes-gains')
} catch (err) {
setError(err.message)
// Le jeton a été consommé par le backend : on en redemande un.
captchaRef.current?.reset()
setCaptchaToken('')
} finally {
setLoading(false)
}
@@ -118,9 +125,11 @@ export default function Connexion() {
</div>
</div>
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
<button
type="submit"
disabled={loading}
disabled={loading || (CAPTCHA_ENABLED && !captchaToken)}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
>
{loading ? 'Connexion...' : 'Se connecter'}
@@ -136,7 +145,7 @@ export default function Connexion() {
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={handleGoogleLogin}
onClick={() => handleOAuthLogin('google')}
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"
>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
@@ -149,9 +158,8 @@ export default function Connexion() {
</button>
<button
type="button"
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-500 cursor-not-allowed opacity-60"
onClick={() => handleOAuthLogin('facebook')}
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"
>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="12" fill="#1877F2" />

View File

@@ -3,6 +3,8 @@ import { Link, useNavigate } from 'react-router-dom'
import { registerApi, verifyCodeApi } from '../services/auth'
import { useAuth } from '../context/AuthContext'
import usePageMeta from '../hooks/usePageMeta'
import Captcha from '../components/Captcha'
import { CAPTCHA_ENABLED } from '../config/captcha'
// Une naissance ne peut pas être dans le futur : borne haute du sélecteur.
const AUJOURD_HUI = new Date().toISOString().split('T')[0]
@@ -50,6 +52,8 @@ export default function Inscription() {
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [captchaToken, setCaptchaToken] = useState('')
const captchaRef = useRef()
// Code à 4 chiffres
const [digits, setDigits] = useState(['', '', '', ''])
@@ -59,10 +63,10 @@ export default function Inscription() {
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 = () => {
// Même route que la connexion : le provider crée le compte à la première venue.
const handleOAuthSignup = (provider) => {
const apiUrl = import.meta.env.VITE_API_URL || ''
window.location.href = `${apiUrl}/api/auth/google`
window.location.href = `${apiUrl}/api/auth/${provider}`
}
// Étape 1 — Inscription
@@ -85,11 +89,15 @@ export default function Inscription() {
birthdate: form.birthdate,
gender: form.gender,
marketingOptIn,
captchaToken,
})
setEmail(form.email)
setStep('verify')
} catch (err) {
setError(err.message)
// Le jeton a été consommé par le backend : on en redemande un.
captchaRef.current?.reset()
setCaptchaToken('')
} finally {
setLoading(false)
}
@@ -183,7 +191,7 @@ export default function Inscription() {
Le code expire dans 15 minutes.{' '}
<button
type="button"
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError('') }}
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError(''); setCaptchaToken('') }}
className="text-[#006B6B] hover:underline"
>
Recommencer
@@ -281,7 +289,9 @@ export default function Inscription() {
</span>
</label>
<button type="submit" disabled={loading || !acceptedTerms}
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
<button type="submit" disabled={loading || !acceptedTerms || (CAPTCHA_ENABLED && !captchaToken)}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors">
{loading ? 'Inscription…' : "S'inscrire"}
</button>
@@ -294,7 +304,7 @@ export default function Inscription() {
</div>
<div className="grid grid-cols-2 gap-3">
<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">
<button type="button" onClick={() => handleOAuthSignup('google')} 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">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
@@ -303,7 +313,7 @@ export default function Inscription() {
</svg>
Google
</button>
<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-500 cursor-not-allowed opacity-60">
<button type="button" onClick={() => handleOAuthSignup('facebook')} 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">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="12" fill="#1877F2" />
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />

View File

@@ -7,11 +7,11 @@ function apiError(message, status) {
return err
}
export async function loginApi({ email, password }) {
export async function loginApi({ email, password, captchaToken }) {
const res = await fetch(`${BASE}/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
body: JSON.stringify({ email, password, captchaToken: captchaToken || undefined }),
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur de connexion.', res.status)
@@ -26,6 +26,7 @@ export async function registerApi({
birthdate,
gender,
marketingOptIn,
captchaToken,
}) {
const res = await fetch(`${BASE}/register`, {
method: 'POST',
@@ -40,6 +41,7 @@ export async function registerApi({
birthdate: birthdate || undefined,
gender: gender || undefined,
marketingOptIn: marketingOptIn ?? undefined,
captchaToken: captchaToken || undefined,
}),
})
const data = await res.json()

View File

@@ -42,7 +42,8 @@ describe('Connexion', () => {
await userEvent.click(screen.getByRole('button', { name: /se connecter/i }))
expect(await screen.findByText('Email ou mot de passe incorrect.')).toBeInTheDocument()
expect(loginApi).toHaveBeenCalledWith({ email: 'test@example.com', password: 'mauvais-mdp' })
// captchaToken vide : aucun widget Turnstile en test (voir vite.config.js).
expect(loginApi).toHaveBeenCalledWith({ email: 'test@example.com', password: 'mauvais-mdp', captchaToken: '' })
})
it('connecte un client et redirige vers /mes-gains', async () => {

View File

@@ -13,5 +13,8 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: './src/setupTests.js',
// Le .env local est aussi chargé en test : sans cette surcharge, le widget
// Turnstile bloquerait la soumission des formulaires dans jsdom.
env: { VITE_TURNSTILE_SITE_KEY: '' },
},
})