ajout de la connection via facebook et du captcha
This commit is contained in:
19
frontend/src/components/Captcha.jsx
Normal file
19
frontend/src/components/Captcha.jsx
Normal 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('')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
8
frontend/src/config/captcha.js
Normal file
8
frontend/src/config/captcha.js
Normal 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)
|
||||
@@ -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.'
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user