From 21648ab171417d46229605e005e428ce302a7612 Mon Sep 17 00:00:00 2001 From: Alexis SANOGO Date: Mon, 17 Aug 2026 21:50:21 +0200 Subject: [PATCH 01/11] Ajout de logo authentification Facebook / Google --- frontend/src/components/UserNav.jsx | 3 +-- frontend/src/pages/Connexion.jsx | 26 ++++++++++++++++++-------- frontend/src/pages/Inscription.jsx | 18 ++++++++++++++++-- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/UserNav.jsx b/frontend/src/components/UserNav.jsx index 6582af3..88631c2 100644 --- a/frontend/src/components/UserNav.jsx +++ b/frontend/src/components/UserNav.jsx @@ -47,8 +47,7 @@ export default function UserNav({ active }) { }`} > {tab.icon} - {tab.label} - {tab.label.split(' ')[0]} + {tab.label} ))} diff --git a/frontend/src/pages/Connexion.jsx b/frontend/src/pages/Connexion.jsx index 3a9b1d2..81d5329 100644 --- a/frontend/src/pages/Connexion.jsx +++ b/frontend/src/pages/Connexion.jsx @@ -125,19 +125,29 @@ export default function Connexion() { 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" > -
- G -
- Google + + + +Google diff --git a/frontend/src/pages/Inscription.jsx b/frontend/src/pages/Inscription.jsx index 0e15336..0424790 100644 --- a/frontend/src/pages/Inscription.jsx +++ b/frontend/src/pages/Inscription.jsx @@ -215,11 +215,25 @@ export default function Inscription() {
From 61dcbda58119e383a0f1653236e89ec4a3195878 Mon Sep 17 00:00:00 2001 From: Alexis SANOGO Date: Mon, 17 Aug 2026 23:21:04 +0200 Subject: [PATCH 02/11] FAQ depuis la page d'accueil --- frontend/src/pages/Accueil.jsx | 61 ++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/frontend/src/pages/Accueil.jsx b/frontend/src/pages/Accueil.jsx index e8f3a49..1bc0e85 100644 --- a/frontend/src/pages/Accueil.jsx +++ b/frontend/src/pages/Accueil.jsx @@ -52,6 +52,67 @@ export default function Accueil() { + {/* Questions fréquentes */} +
+

+ Questions fréquentes +

+
+ {[ + { + question: 'Comment obtenir un code ?', + answer: "Achetez un produit Thé Tip Top d'une valeur supérieure à 49 € en boutique ou en ligne : un code unique à 10 caractères figure sur votre ticket de caisse ou votre facture.", + }, + { + question: 'Quels sont les lots à gagner ?', + answer: ( + <> + Tous les tickets sont gagnants ! Selon votre ticket, vous pouvez remporter : +
    +
  • Un infuseur à thé
  • +
  • Une boîte de 100 g de thé détox ou d'infusion
  • +
  • Une boîte de 100 g de thé signature
  • +
  • Un coffret découverte d'une valeur de 39 €
  • +
  • Un coffret découverte d'une valeur de 69 €
  • +
+ Un tirage au sort désigne en plus un grand gagnant qui remporte une année de thé d'une valeur de 360 €. + + ), + }, + { + question: 'Jusqu\'à quand puis-je jouer ?', + answer: 'La participation est possible pendant toute la durée du jeu-concours, soit 30 jours à compter de son ouverture. Consultez la page Règles du jeu pour le détail des dates.', + }, + { + question: 'Que faire si mon code n\'est pas reconnu ?', + answer: "Vérifiez que le code a été saisi sans espace ni erreur. Si le problème persiste, contactez-nous depuis la page Contact.", + }, + { + question: 'Où retirer mon lot ?', + answer: 'En boutique, sur présentation de votre compte utilisateur validé et de la confirmation de gain affichée dans votre espace « Mes gains ».', + }, + ].map((faq) => ( +
+ + {faq.question} + + + + +
+ {faq.answer} +
+
+ ))} +
+
+ ) } From b1272bd008b41f6c579c4e3d9088a8173ac7c5d4 Mon Sep 17 00:00:00 2001 From: Alexis SANOGO Date: Thu, 27 Aug 2026 15:25:53 +0200 Subject: [PATCH 03/11] =?UTF-8?q?Corriger=20les=20textes=20pour=20les=20li?= =?UTF-8?q?miter=20uniquement=20=C3=A0=20la=20boutique=20de=20Nice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/Footer.jsx | 10 +++++++++- frontend/src/pages/Cgu.jsx | 24 +++++++++++++----------- frontend/src/pages/ReglesJeu.jsx | 6 +++--- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx index 0d4420a..35c7bac 100644 --- a/frontend/src/components/Footer.jsx +++ b/frontend/src/components/Footer.jsx @@ -196,9 +196,17 @@ export default function Footer() { {/* Barre basse */}
+
+

+ Jeu avec obligation d'achat. Participation réservée aux personnes majeures résidant en + France métropolitaine, sur présentation d'un ticket de caisse supérieur à 49 € émis + dans la boutique Thé Tip Top de Nice. Le nombre de participations n'augmente pas les + chances de remporter le gros lot. +

+
-

+

© {new Date().getFullYear()} Thé Tip Top. Tous droits réservés.
diff --git a/frontend/src/pages/Cgu.jsx b/frontend/src/pages/Cgu.jsx index 212870c..d813afb 100644 --- a/frontend/src/pages/Cgu.jsx +++ b/frontend/src/pages/Cgu.jsx @@ -50,10 +50,11 @@ export default function Cgu() { non avenue. La société Thé Tip Top se réserve le droit de demander la fourniture d'un justificatif d'identité pour vérifier l'âge déclaré.

-

Conditions géographiques — Le jeu-concours « 100% Gagnant » est - ouvert exclusivement aux résidents de France métropolitaine et des Départements et - Régions d'Outre-Mer (DROM). Sont exclus les ressortissants ou résidents de toute - autre zone géographique. +

Conditions géographiques — La participation au jeu-concours + « 100% Gagnant » est réservée aux personnes majeures résidant en France + métropolitaine. Seuls les tickets de caisse ou factures d'un montant supérieur à + 49 € émis dans la boutique Thé Tip Top de Nice, à l'occasion de son ouverture, + donnent droit à un code de participation.

Personnes exclues — Sont exclus de toute participation les membres du personnel de la société Thé Tip Top et de l'agence Furious Ducks, leurs @@ -77,16 +78,16 @@ export default function Cgu() {

3.1 – Services proposés

Le site du jeu-concours « 100% Gagnant » est un site internet dédié permettant - aux clients de la société Thé Tip Top de participer à un jeu-concours à - l'occasion de l'ouverture de la 10ᵉ boutique de l'enseigne à Nice. Les services - proposés incluent : + aux clients de la société Thé Tip Top de participer à un jeu-concours réservé à + la boutique de Nice, à l'occasion de son ouverture. Les services proposés + incluent :

  • La présentation du jeu-concours, de ses conditions et des lots à gagner
  • La création d'un espace personnel pour chaque participant
  • -
  • La saisie et la validation des codes à 10 caractères figurant sur les tickets de caisse ou factures d'un montant supérieur à 49 €
  • +
  • La saisie et la validation des codes à 10 caractères figurant sur les tickets de caisse ou factures d'un montant supérieur à 49 € émis dans la boutique de Nice
  • La consultation de l'historique des participations et gains obtenus
  • -
  • La réclamation des lots gagnés, en ligne ou en boutique
  • +
  • La réclamation des lots gagnés dans la boutique de Nice
@@ -116,8 +117,9 @@ export default function Cgu() { Passé ce délai, les lots non réclamés sont définitivement perdus.

Remise des lots — Les lots peuvent être réclamés - en boutique, sur présentation du compte utilisateur validé et de la confirmation - de gain. + exclusivement dans la boutique Thé Tip Top de Nice, sur présentation du compte + utilisateur validé et de la confirmation de gain. Aucun site marchand n'étant + ouvert, aucune remise ni expédition à distance n'est possible.

À l'issue du jeu-concours, un tirage au sort est effectué parmi l'ensemble des diff --git a/frontend/src/pages/ReglesJeu.jsx b/frontend/src/pages/ReglesJeu.jsx index dba3432..f4110be 100644 --- a/frontend/src/pages/ReglesJeu.jsx +++ b/frontend/src/pages/ReglesJeu.jsx @@ -15,7 +15,7 @@ export default function ReglesJeu() { Règles du jeu

- Participez au jeu-concours ThéTipTop et découvrez immédiatement + Jeu-concours réservé à notre boutique de Nice : participez et découvrez immédiatement le lot que vous avez gagné.

@@ -31,7 +31,7 @@ export default function ReglesJeu() { { num: '1', title: 'Obtenez un code', - text: "Achetez un produit ThéTipTop d'une valeur supérieure à 49€ en ligne ou en magasin pour recevoir un code unique à 10 chiffres.", + text: "Achetez un produit ThéTipTop d'une valeur supérieure à 49€ dans notre boutique de Nice pour recevoir un code unique à 10 chiffres.", }, { num: '2', @@ -80,7 +80,7 @@ export default function ReglesJeu() { Ne manquez pas votre chance !

- Inscrivez-vous, entrez votre code de ticket et retirez votre lot en magasin ou en ligne. + Inscrivez-vous, entrez votre code de ticket et retirez votre lot dans notre boutique de Nice.

{!isLoggedIn && ( From 4f0b6d22f244cbddb5a1e8dca70ea2d347c64b92 Mon Sep 17 00:00:00 2001 From: Alexis SANOGO Date: Thu, 27 Aug 2026 16:28:38 +0200 Subject: [PATCH 04/11] =?UTF-8?q?Ajouter=20des=20textes=20et=20fichier=20p?= =?UTF-8?q?df=20pour=20le=20r=C3=A8glement=20du=20jeu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/public/robots.txt | 2 + frontend/src/components/Footer.jsx | 8 ++- frontend/src/pages/Accueil.jsx | 112 ++++++++++++++++++----------- frontend/src/pages/Cgu.jsx | 10 +++ frontend/src/pages/ReglesJeu.jsx | 9 +++ 5 files changed, 98 insertions(+), 43 deletions(-) diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt index 86a7682..6dc2c3c 100644 --- a/frontend/public/robots.txt +++ b/frontend/public/robots.txt @@ -7,6 +7,8 @@ Allow: /regles-jeu Allow: /contact Allow: /mentions-legales Allow: /confidentialite +Allow: /accessibilite +Allow: /cgu Allow: /sitemap Allow: /inscription Allow: /connexion diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx index 35c7bac..87c578b 100644 --- a/frontend/src/components/Footer.jsx +++ b/frontend/src/components/Footer.jsx @@ -201,7 +201,13 @@ export default function Footer() { Jeu avec obligation d'achat. Participation réservée aux personnes majeures résidant en France métropolitaine, sur présentation d'un ticket de caisse supérieur à 49 € émis dans la boutique Thé Tip Top de Nice. Le nombre de participations n'augmente pas les - chances de remporter le gros lot. + chances de remporter le gros lot.{' '} + + Télécharger le règlement (PDF) +

diff --git a/frontend/src/pages/Accueil.jsx b/frontend/src/pages/Accueil.jsx index 1bc0e85..f150fca 100644 --- a/frontend/src/pages/Accueil.jsx +++ b/frontend/src/pages/Accueil.jsx @@ -1,19 +1,80 @@ import { Link } from 'react-router-dom' +import bannerImg from '../assets/img/banner-the-tip-top.webp' + +const faqs = [ + { + question: 'Comment obtenir un code ?', + answer: "Achetez un produit Thé Tip Top d'une valeur supérieure à 49 € dans notre boutique de Nice : un code unique à 10 caractères figure sur votre ticket de caisse ou votre facture.", + }, + { + question: 'Quels sont les lots à gagner ?', + answer: ( + <> + Tous les tickets sont gagnants ! Selon votre ticket, vous pouvez remporter : +
    +
  • Un infuseur à thé
  • +
  • Une boîte de 100 g de thé détox ou d'infusion
  • +
  • Une boîte de 100 g de thé signature
  • +
  • Un coffret découverte d'une valeur de 39 €
  • +
  • Un coffret découverte d'une valeur de 69 €
  • +
+ Un tirage au sort désigne en plus un grand gagnant qui remporte une année de thé d'une valeur de 360 €. + + ), + plainAnswer: "Tous les tickets sont gagnants ! Selon votre ticket, vous pouvez remporter : un infuseur à thé, une boîte de 100 g de thé détox ou d'infusion, une boîte de 100 g de thé signature, un coffret découverte d'une valeur de 39 €, ou un coffret découverte d'une valeur de 69 €. Un tirage au sort désigne en plus un grand gagnant qui remporte une année de thé d'une valeur de 360 €.", + }, + { + question: 'Jusqu\'à quand puis-je jouer ?', + answer: 'La participation est possible pendant toute la durée du jeu-concours, soit 30 jours à compter de son ouverture. Consultez la page Règles du jeu pour le détail des dates.', + }, + { + question: 'Que faire si mon code n\'est pas reconnu ?', + answer: "Vérifiez que le code a été saisi sans espace ni erreur. Si le problème persiste, contactez-nous depuis la page Contact.", + }, + { + question: 'Où retirer mon lot ?', + answer: 'Dans notre boutique de Nice, sur présentation de votre compte utilisateur validé et de la confirmation de gain affichée dans votre espace « Mes gains ».', + }, +] + +const faqJsonLd = { + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: faqs.map((faq) => ({ + '@type': 'Question', + name: faq.question, + acceptedAnswer: { + '@type': 'Answer', + text: faq.plainAnswer || faq.answer, + }, + })), +} export default function Accueil() { return (
+
diff --git a/frontend/public/og-image.webp b/frontend/public/og-image.webp new file mode 100644 index 0000000000000000000000000000000000000000..37a70630d1061519967edd0593868277e85b107f GIT binary patch literal 236594 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

- 18 rue Léon Frot -
- 75011 Paris, France -

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 WK2{+OD2- thetiptop.gp4@gmail.com - -

diff --git a/frontend/src/hooks/usePageMeta.js b/frontend/src/hooks/usePageMeta.js index 5ec3898..18a0730 100644 --- a/frontend/src/hooks/usePageMeta.js +++ b/frontend/src/hooks/usePageMeta.js @@ -1,29 +1,52 @@ import { useEffect } from 'react' +import { useLocation } from 'react-router-dom' + +// Domaine de production, utilisé pour construire des URLs absolues +// (canonical, og:url) à partir du chemin de la route courante. +const SITE_URL = 'https://www.dsp5-archi-o24a-4-5-g4.fr' + +// Change la valeur d'un attribut sur le premier élément trouvé par le +// sélecteur, et retourne de quoi la restaurer plus tard. N'écrit rien si +// l'élément est absent du (les balises de base vivent dans +// index.html ; ce hook ne fait que les mettre à jour, jamais les créer). +function setAttr(selector, attr, value) { + if (value == null) return null + const el = document.querySelector(selector) + if (!el) return null + const previous = el.getAttribute(attr) + el.setAttribute(attr, value) + return () => el.setAttribute(attr, previous ?? '') +} + +// Permet à une page de définir son propre , sa meta description, son +// URL canonique et ses balises Open Graph/Twitter le temps qu'elle est +// affichée. La SPA n'a qu'un seul index.html : sans ce hook, toutes les +// routes partagent les mêmes balises. Les valeurs précédentes sont +// restaurées au démontage, donc quitter la page revient à l'état d'avant +// (celui d'index.html, ou celui d'une page parente si elles s'enchaînent). +export default function usePageMeta({ title, description, image } = {}) { + const { pathname } = useLocation() -// Permet à une page de définir son propre <title>/<meta description> le -// temps qu'elle est affichée. La SPA n'a qu'un seul index.html : sans ce -// hook, toutes les routes partagent le même title/meta. Les valeurs -// précédentes sont restaurées au démontage, donc quitter la page revient à -// l'état d'avant (celui d'index.html, ou celui d'une page parente si elles -// s'enchaînent). -export default function usePageMeta({ title, description } = {}) { useEffect(() => { const previousTitle = document.title - if (title) { - document.title = title - } + if (title) document.title = title - const metaEl = document.querySelector('meta[name="description"]') - const previousDescription = metaEl?.getAttribute('content') ?? null - if (description && metaEl) { - metaEl.setAttribute('content', description) - } + const url = `${SITE_URL}${pathname}` + const restores = [ + setAttr('meta[name="description"]', 'content', description), + setAttr('link[rel="canonical"]', 'href', url), + setAttr('meta[property="og:title"]', 'content', title), + setAttr('meta[property="og:description"]', 'content', description), + setAttr('meta[property="og:url"]', 'content', url), + setAttr('meta[property="og:image"]', 'content', image), + setAttr('meta[name="twitter:title"]', 'content', title), + setAttr('meta[name="twitter:description"]', 'content', description), + setAttr('meta[name="twitter:image"]', 'content', image), + ].filter(Boolean) return () => { document.title = previousTitle - if (metaEl && previousDescription !== null) { - metaEl.setAttribute('content', previousDescription) - } + restores.forEach((restore) => restore()) } - }, [title, description]) + }, [title, description, image, pathname]) } diff --git a/frontend/src/pages/Accessibilite.jsx b/frontend/src/pages/Accessibilite.jsx index 645ee13..13ad379 100644 --- a/frontend/src/pages/Accessibilite.jsx +++ b/frontend/src/pages/Accessibilite.jsx @@ -1,4 +1,11 @@ +import usePageMeta from '../hooks/usePageMeta' + export default function Accessibilite() { + usePageMeta({ + title: 'Déclaration d’accessibilité | Thé Tip Top', + description: "État de conformité RGAA du site Thé Tip Top et procédure pour signaler un problème d'accessibilité rencontré sur le site.", + }) + return ( <main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16"> <div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10"> diff --git a/frontend/src/pages/Accueil.jsx b/frontend/src/pages/Accueil.jsx index 4282489..c60e30e 100644 --- a/frontend/src/pages/Accueil.jsx +++ b/frontend/src/pages/Accueil.jsx @@ -3,9 +3,6 @@ import { Link } from 'react-router-dom' import usePageMeta from '../hooks/usePageMeta' import bannerImg from '../assets/img/banner-accueil.webp' -// Tous les lots possibles hors grand lot (déjà mis en avant dans sa propre -// carte). Chaque lot devient sa propre carte flottante, étalée en quinconce -// le long de la partie droite de l'illustration plutôt qu'empilée en liste. const gains = [ { label: 'Infuseur à thé', top: '26%', right: '26%', delay: 700, variant: 'a' }, { label: 'Boîte 100 g détox ou infusion', top: '22%', right: '1%', delay: 850, variant: 'b' }, @@ -14,9 +11,6 @@ const gains = [ { label: 'Coffret découverte 69 €', top: '84%', right: '24%', delay: 1300, variant: 'a' }, ] -// Légère inclinaison 3D des cartes de la bannière au passage de la souris, -// façon « tilt card ». Fonctions déclarées hors du composant : elles ne -// dépendent d'aucun state, inutile de les recréer à chaque rendu. function handleCardTilt(e) { const el = e.currentTarget const rect = el.getBoundingClientRect() @@ -28,11 +22,6 @@ function resetCardTilt(e) { e.currentTarget.style.transform = '' } -// Petites animations de thé générées en JS : position, taille, durée et -// délai tirés au sort une seule fois (useMemo) pour parsemer toute la -// bannière de feuilles/tasses/théières/cuillères qui dérivent doucement, -// sans jamais se superposer deux fois de la même façon. Taille et opacité -// relevées pour qu'elles restent bien visibles sur le fond teal. const TEA_EMOJI = ['🍃', '🍵', '🫖', '☕', '🥄', '✨'] function createTeaParticles(count) { @@ -100,12 +89,10 @@ const faqJsonLd = { export default function Accueil() { usePageMeta({ - title: 'Jeu-concours 100 % Gagnant | Thé Tip Top — Thés bio et faits main', + title: 'Jeu-concours 100 % Gagnant du 2 au 31 octobre | Thé Tip Top', description: "Du 2 au 31 octobre, tout ticket de plus de 49 € de notre boutique de Nice porte un code 100 % gagnant. Saisissez le vôtre et repartez avec votre lot.", }) - // Tirées au sort une seule fois au montage : la bannière ne doit pas - // réordonner ses particules de thé à chaque re-render. const teaParticles = useMemo(() => createTeaParticles(30), []) return ( @@ -113,25 +100,14 @@ export default function Accueil() { <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }} /> - {/* Bannière : fond teal plein cadre, illustration très grande et animée à l'entrée, - textes et cartes de gains répartis en plusieurs blocs pour occuper toute la zone - plutôt qu'une seule colonne dense. La composition « poster » en absolute (position: - static → absolute) ne s'active qu'à partir de lg (1024px) : en dessous — mobile ET - tablette —, un écran trop étroit ferait chevaucher le titre et l'illustration de façon - illisible (constaté sur iPad portrait), donc tout reste en empilement normal, dans - l'ordre du DOM, jusqu'à avoir vraiment la place. */} <section className="relative bg-[#006B6B] overflow-hidden"> <div className="max-w-7xl mx-auto px-4 sm:px-6 relative py-10 lg:py-0 lg:h-[680px] xl:h-[780px]"> - {/* Halo doux derrière l'illustration (uniquement pertinent à partir de lg, où - l'illustration passe en fond de la composition) */} <div className="hidden lg:block absolute right-[-10%] top-1/2 -translate-y-1/2 w-[75%] h-[75%] rounded-full bg-white/10 blur-3xl" aria-hidden="true" /> - {/* Particules de thé générées en JS (position/taille/durée tirées au sort dans - teaParticles), parsemées sur toute la bannière derrière le contenu. */} <div className="pointer-events-none absolute inset-0 overflow-hidden z-0" aria-hidden="true"> {teaParticles.map((p) => ( <span @@ -152,7 +128,6 @@ export default function Accueil() { ))} </div> - {/* Titre */} <div className="static lg:absolute lg:left-0 lg:top-10 max-w-full lg:max-w-md mb-6 lg:mb-0"> <p className="inline-block text-sm sm:text-base font-bold uppercase tracking-widest text-amber-300 border border-white/70 rounded-lg px-3.5 py-2 mb-3"> Du 02 au 31 octobre 2026 @@ -163,10 +138,6 @@ export default function Accueil() { </h1> </div> - {/* Illustration : très grande à partir de lg, débordant du cadre en haut/bas (rogné - proprement par le overflow-hidden de la section) et glissant à l'entrée. En - dessous de lg, recadrée (object-cover) sur le personnage pour ne pas gaspiller - l'espace transparent intégré au visuel d'origine. */} <div className="relative -mx-4 sm:mx-0 lg:mx-0 mb-6 lg:mb-0 lg:contents"> <div className="relative aspect-[16/10] sm:aspect-[21/9] overflow-hidden lg:hidden"> <img @@ -184,27 +155,14 @@ export default function Accueil() { /> </div> - {/* Sous-titre : mis en évidence dans une carte « verre dépoli » distincte du fond - teal uni (bordure d'accent + fond translucide flouté), pour qu'il se détache - clairement plutôt que de se fondre dans le reste du texte blanc de la bannière. */} <p className="static lg:absolute lg:left-0 lg:top-1/2 lg:-translate-y-1/2 max-w-full lg:max-w-md text-lg sm:text-2xl lg:text-3xl font-semibold text-white leading-snug [text-wrap:balance] mb-6 lg:mb-0 bg-white/10 backdrop-blur-sm border-l-4 border-amber-300 rounded-2xl px-4 py-3 sm:px-6 sm:py-4 shadow-lg"> À l'occasion de l'ouverture de notre{' '} <span className="text-amber-300">10<sup>e</sup> boutique</span> à Nice : chaque code distribué correspond à un lot réel, attribué à l'avance. </p> - {/* Cartes de gains : le grand lot (agrandi) + tous les autres lots possibles, en - cartes flottantes autour de l'illustration à partir de lg uniquement — masquées - sur mobile/tablette, où elles surchargeaient l'écran en plus du reste du contenu. - Chaque carte flotte doucement en continu (animate-card-float-*) et s'incline - légèrement au passage de la souris (onMouseMove → handleCardTilt), deux animations - portées par des conteneurs séparés pour ne pas se marcher dessus. */} <div className="hidden lg:contents"> - {/* Grand lot : décalé plus bas et plus à droite que les autres cartes du haut - (elle touchait « Boîte détox ou infusion »), dans le grand espace libre entre - cette carte et « Coffret découverte 39 € », pour amorcer un arc de cercle autour - de l'illustration plutôt qu'un empilement dans le même coin. */} <div className="shrink-0 lg:absolute lg:right-[1%] lg:top-[45%] animate-banner-badge [animation-delay:500ms]"> <div className="animate-card-float-a"> <div @@ -220,14 +178,6 @@ export default function Accueil() { </div> </div> - {/* Les autres lots : une carte par lot, éclatées en quinconce (position en % - définie dans le tableau `gains`) plutôt qu'empilées dans une seule colonne. - top/right en style inline : ils ne s'appliquent qu'à partir de lg (seul - breakpoint où la carte passe en absolute), sans effet en dessous. - whitespace-nowrap plutôt qu'un max-w fixe : une carte plus étroite que sa - ligne de texte forçait un retour à la ligne qui laissait du blanc (ex. « Boîte - 100 g détox ou » / « infusion »). En nowrap, chaque carte se limite exactement - à la largeur de son texte, sur une seule ligne, sans espace perdu. */} {gains.map((gain) => ( <div key={gain.label} @@ -247,7 +197,6 @@ export default function Accueil() { ))} </div> - {/* CTA, remonté par rapport au bord bas de la bannière */} <div className="static lg:absolute lg:left-0 lg:bottom-24 xl:bottom-28 flex flex-col sm:flex-row gap-3 sm:gap-4"> <Link to="/saisir-code-unique" @@ -265,7 +214,6 @@ export default function Accueil() { </div> </section> - {/* Comment participer */} <section className="max-w-5xl mx-auto px-4 pt-6 sm:px-6 pb-12 sm:pb-20"> <h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center"> Comment participer ? @@ -284,7 +232,6 @@ export default function Accueil() { </ol> </section> - {/* Questions fréquentes */} <section className="max-w-3xl mx-auto px-4 sm:px-6 pb-16 sm:pb-24"> <h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center"> Questions fréquentes diff --git a/frontend/src/pages/Cgu.jsx b/frontend/src/pages/Cgu.jsx index 4506291..5831143 100644 --- a/frontend/src/pages/Cgu.jsx +++ b/frontend/src/pages/Cgu.jsx @@ -1,6 +1,12 @@ import { Link } from 'react-router-dom' +import usePageMeta from '../hooks/usePageMeta' export default function Cgu() { + usePageMeta({ + title: 'Conditions générales d’utilisation | Thé Tip Top', + description: "CGU du site Thé Tip Top : conditions d'accès au jeu-concours, inscription, participation et gestion de votre compte.", + }) + return ( <main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16"> <div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10"> diff --git a/frontend/src/pages/Confidentialite.jsx b/frontend/src/pages/Confidentialite.jsx index 3f2da31..b14522f 100644 --- a/frontend/src/pages/Confidentialite.jsx +++ b/frontend/src/pages/Confidentialite.jsx @@ -1,4 +1,11 @@ +import usePageMeta from '../hooks/usePageMeta' + export default function Confidentialite() { + usePageMeta({ + title: 'Politique de confidentialité | Thé Tip Top', + description: "Comment Thé Tip Top collecte et protège vos données personnelles dans le cadre du jeu-concours, et comment exercer vos droits RGPD.", + }) + return ( <main className="bg-[#F0F0F0] min-h-screen px-4 sm:px-6 py-10 sm:py-16"> <div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10"> @@ -19,7 +26,7 @@ export default function Confidentialite() { </h2> <p className="text-gray-700 leading-relaxed text-sm sm:text-base"> Le responsable du traitement des données collectées via le site du jeu-concours - « 100% Gagnant » est la société Thé Tip Top, située au 18 rue Léon Frot, 75011 Paris. + « 100% Gagnant » est la société Thé Tip Top. </p> </section> diff --git a/frontend/src/pages/Connexion.jsx b/frontend/src/pages/Connexion.jsx index 74d9e6c..fc7adbd 100644 --- a/frontend/src/pages/Connexion.jsx +++ b/frontend/src/pages/Connexion.jsx @@ -2,8 +2,14 @@ import { useState } 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' export default function Connexion() { + usePageMeta({ + title: 'Connexion | Thé Tip Top', + description: "Connectez-vous à votre espace participant Thé Tip Top pour saisir votre code et suivre vos gains au jeu-concours 100 % Gagnant.", + }) + const navigate = useNavigate() const location = useLocation() const { login } = useAuth() diff --git a/frontend/src/pages/Contact.jsx b/frontend/src/pages/Contact.jsx index 84f7aec..a56f87a 100644 --- a/frontend/src/pages/Contact.jsx +++ b/frontend/src/pages/Contact.jsx @@ -1,6 +1,12 @@ import imgContact from '../assets/img/img-contact.webp' +import usePageMeta from '../hooks/usePageMeta' export default function Contact() { + usePageMeta({ + title: 'Contact | Thé Tip Top', + description: "Une question sur le jeu-concours, votre participation ou votre lot ? Contactez l'équipe Thé Tip Top par email, téléphone ou via le formulaire en ligne.", + }) + return ( <main className="bg-[#F0F0F0] px-4 sm:px-6 py-10 sm:py-16"> <div className="max-w-6xl mx-auto"> @@ -19,19 +25,6 @@ export default function Contact() { </p> <div className="space-y-5 text-gray-700"> - <div> - <h2 className="font-semibold text-[#006B6B] mb-1"> - Adresse - </h2> - <p className="text-sm sm:text-base"> - ThéTipTop - <br /> - 18 rue Léon Frot - <br /> - 75011 Paris, France - </p> - </div> - <div> <h2 className="font-semibold text-[#006B6B] mb-1"> Email diff --git a/frontend/src/pages/Inscription.jsx b/frontend/src/pages/Inscription.jsx index 4b2c54f..70b76b6 100644 --- a/frontend/src/pages/Inscription.jsx +++ b/frontend/src/pages/Inscription.jsx @@ -2,6 +2,7 @@ import { useState, useRef } from 'react' import { Link, useNavigate } from 'react-router-dom' import { registerApi, verifyCodeApi } from '../services/auth' import { useAuth } from '../context/AuthContext' +import usePageMeta from '../hooks/usePageMeta' // Une naissance ne peut pas être dans le futur : borne haute du sélecteur. const AUJOURD_HUI = new Date().toISOString().split('T')[0] @@ -23,6 +24,11 @@ function hasAtLeast18Years(birthdate) { const GENRES = ['Femme', 'Homme', 'Autre'] export default function Inscription() { + usePageMeta({ + title: 'Créer un compte | Thé Tip Top', + description: "Inscrivez-vous en quelques instants pour participer au jeu-concours 100 % Gagnant Thé Tip Top et saisir le code de votre ticket de caisse.", + }) + const navigate = useNavigate() const { login } = useAuth() diff --git a/frontend/src/pages/MentionsLegales.jsx b/frontend/src/pages/MentionsLegales.jsx index 304ddb1..388468b 100644 --- a/frontend/src/pages/MentionsLegales.jsx +++ b/frontend/src/pages/MentionsLegales.jsx @@ -1,4 +1,11 @@ +import usePageMeta from '../hooks/usePageMeta' + export default function MentionsLegales() { + usePageMeta({ + title: 'Mentions légales | Thé Tip Top', + description: "Mentions légales du site Thé Tip Top : éditeur, hébergeur, propriété intellectuelle et informations relatives au jeu-concours 100 % Gagnant.", + }) + return ( <main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16"> <div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10"> diff --git a/frontend/src/pages/ReglesJeu.jsx b/frontend/src/pages/ReglesJeu.jsx index e7ef61c..4a84bab 100644 --- a/frontend/src/pages/ReglesJeu.jsx +++ b/frontend/src/pages/ReglesJeu.jsx @@ -1,10 +1,16 @@ import { Link } from 'react-router-dom' import { useAuth } from '../context/AuthContext' +import usePageMeta from '../hooks/usePageMeta' export default function ReglesJeu() { const { user } = useAuth() const isLoggedIn = !!user + usePageMeta({ + title: 'Règles du jeu-concours 100 % Gagnant | Thé Tip Top', + description: "Comment participer au jeu-concours Thé Tip Top : obtenir un code, créer un compte, découvrir votre lot et tenter de remporter le grand gagnant.", + }) + return ( <div className="bg-gray-50 min-h-screen"> diff --git a/frontend/src/pages/Sitemap.jsx b/frontend/src/pages/Sitemap.jsx index 49966dc..e794cc1 100644 --- a/frontend/src/pages/Sitemap.jsx +++ b/frontend/src/pages/Sitemap.jsx @@ -1,5 +1,6 @@ import { Link } from 'react-router-dom' import { useAuth} from '../context/AuthContext' +import usePageMeta from '../hooks/usePageMeta' const sections = [ { @@ -45,6 +46,11 @@ const sections = [ ] export default function Sitemap() { + usePageMeta({ + title: 'Plan du site | Thé Tip Top', + description: "Retrouvez toutes les pages du site Thé Tip Top : jeu-concours, compte participant, informations légales et espaces professionnels.", + }) + const { user } = useAuth() const isAdmin = user?.role === 'ADMIN' const visibleSections = sections.filter( From 28d743deb5bb9f94a48f98235717fc695481c01c Mon Sep 17 00:00:00 2001 From: Alexis SANOGO <alexis.sanogo225@gmail.com> Date: Fri, 4 Sep 2026 16:08:22 +0200 Subject: [PATCH 09/11] Ajouter SSR prerending, espace employe, formulaire inscription employee depuis admin --- .DS_Store | Bin 0 -> 8196 bytes backend/src/routes/employee.routes.js | 4 +- frontend/Dockerfile | 3 +- frontend/eslint.config.js | 10 ++ frontend/nginx.conf | 8 +- frontend/package-lock.json | 97 ++++++++++-- frontend/package.json | 4 +- frontend/scripts/prerender.js | 54 +++++++ frontend/src/pages/Admin.jsx | 208 ++++++++++---------------- frontend/src/pages/AuthCallback.jsx | 55 +++---- frontend/src/pages/Profile.jsx | 2 +- frontend/src/services/admin.js | 11 ++ 12 files changed, 279 insertions(+), 177 deletions(-) create mode 100644 .DS_Store create mode 100644 frontend/scripts/prerender.js diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..afd06601871a1d7b6a11cd619132424afa6c1539 GIT binary patch literal 8196 zcmeHL%}x|S5UxVhSrHu&5RICNdod9e@h`@M<Ek;}0pb#cfUYw;%{nr4uQRhCBFUch z=EVo_0Zcr3)EDq*ym{~iym;2?pH*NX#)FB*?xg#x>8h`~`<wLi_5uKw8qrCBK>%>5 zN_4xZ*`P?jtZk*k^hhTXAAkXeXfzSkFlmdn9aaIWfK|XMU=^?m{0$1wnJtU6$+@q+ zwzUda1^!C~<oCg&D$zIC)Tllk)Tk=}VJCH~LN?t46vs2@8*FM+RLZGu4``@pt{AB4 z<o9Gap>MFMQPTl69ng%7=7xf5bmDm`9MIQjTdROopj`pEZMH)Q0aRgb_5EFh<dK`H zMD_SLRmcL^1`);8AprqP`mX;Y_Vv2h0g4qSa|*}E5-?9(0U6~ut}-nUE>Ul?HWwaV zm&<`!NQucI-)Nrr{8#beVpb0mHP<&`J&Ch?c=!W$Z0X#(ZM)+*UCy2IeX&^1gS?TI zf^=FwO$d>MEjtKr#cZ+S_FWb{4`R+zMTjFtAv3pQ9*M=0Xz(ag+_H8!g+ig?4lXaB z9vK<(PM<onGUP3v9~qYR*_D-o(_cJs;bvtnndjmut$_-fj~sOC=O67evf6Zrs|#jb z__4LFPp;cqSz$-l&hA~i-Jae(J$*g>{R4Xk_wDaJaIoU;34`ifEtNCAEE18Hk_M}| zyE9t%XjSm>lr7MDCv)~FFUOC`)w_;xlSOxbc1<kRDW>mdhe~=aWsFbP(l{EMoy}N2 zxyqiBqg7LpB;z8B^H}hy88v#&=12Lgpb_N^#_H0Q-C}7**v6Th3eq&-HC9@>lLR44 z#&}8OxtO1fX%%wjYRGausK&fjy!b`SSt$BPwB<svrEt-|+EljF*XlBe#kUP#*%MrF zM(lCzDf%}|hyzFA5|m*E9zqjdz)N@q@8CUrhOh7qyKw*y<53*KGk6Yt9L4K6jS<H9 z05hGTqxIeonT!VT2^nqgeW9H4MDQB<B(Dput9>yCG3}9zc1uiql&#%W9rD;*y_@NA z(;~BaR3-Wbn;K=8?3d{z+VfuuVT)DZA5vh8p6j5z|6kqw|NnnzMRuxI0jt38DuA6= z%2!InQm6U`Y<bFiRPRxhMaDHXDhf4gehARBxsIz?k3uEKH`vsusMN2hg!(sx{UboC LV~bVb&noa8O;{Ns literal 0 HcmV?d00001 diff --git a/backend/src/routes/employee.routes.js b/backend/src/routes/employee.routes.js index c5b203c..0665679 100644 --- a/backend/src/routes/employee.routes.js +++ b/backend/src/routes/employee.routes.js @@ -36,7 +36,7 @@ const router = express.Router(); router.get( "/ticket/:code", authMiddleware, - roleMiddleware("EMPLOYEE", "ADMIN"), + roleMiddleware("EMPLOYEE"), getGainByTicketCode ); @@ -69,7 +69,7 @@ router.get( router.patch( "/claim/:id/deliver", authMiddleware, - roleMiddleware("EMPLOYEE", "ADMIN"), + roleMiddleware("EMPLOYEE"), deliverClaim ); diff --git a/frontend/Dockerfile b/frontend/Dockerfile index f5e98df..f5f20fa 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,9 +1,10 @@ -FROM node:22-alpine AS build +FROM node:22-slim AS build WORKDIR /app COPY package*.json ./ RUN npm ci +RUN npx playwright install --with-deps chromium COPY . . diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index ea36dd3..444d863 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -8,6 +8,7 @@ export default defineConfig([ globalIgnores(['dist']), { files: ['**/*.{js,jsx}'], + ignores: ['scripts/**'], extends: [ js.configs.recommended, reactHooks.configs.flat.recommended, @@ -18,4 +19,13 @@ export default defineConfig([ parserOptions: { ecmaFeatures: { jsx: true } }, }, }, + { + // Scripts Node exécutés à la compilation (ex. prerender.js), jamais + // dans le navigateur : globals Node, pas de règles React. + files: ['scripts/**/*.js'], + extends: [js.configs.recommended], + languageOptions: { + globals: globals.node, + }, + }, ]) diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 3aa17e6..5d8110f 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -6,6 +6,12 @@ server { index index.html; location / { - try_files $uri $uri/ /index.html; + # $uri/index.html (et non $uri/) : sert directement le fichier des + # pages pré-rendues (ex. /accueil -> dist/accueil/index.html) sans + # passer par la redirection 301 vers l'URL à slash final que nginx + # déclenche pour une résolution de type répertoire ($uri/). Les + # canonical/sitemap pointent vers les URLs sans slash : elles + # doivent répondre 200 directement, pas via un redirect. + try_files $uri $uri/index.html /index.html; } } diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 167614b..3da5c2b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -26,6 +26,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "jsdom": "^29.1.1", + "playwright": "^1.62.1", "tailwindcss": "^4.3.0", "vite": "^8.0.12", "vitest": "^4.1.10" @@ -120,7 +121,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -441,7 +441,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -490,11 +489,33 @@ } ], "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", @@ -1442,7 +1463,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -1489,7 +1511,6 @@ "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1500,7 +1521,6 @@ "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -1650,7 +1670,6 @@ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1691,6 +1710,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -1701,6 +1721,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -1794,7 +1815,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", @@ -1974,7 +1994,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/electron-to-chromium": { "version": "1.5.408", @@ -2046,7 +2067,6 @@ "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -2923,6 +2943,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -3130,7 +3151,6 @@ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3138,6 +3158,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -3183,6 +3250,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -3207,7 +3275,6 @@ "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" } @@ -3217,7 +3284,6 @@ "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" }, @@ -3230,7 +3296,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-router": { "version": "7.18.2", @@ -3625,7 +3692,6 @@ "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", @@ -4183,7 +4249,6 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/frontend/package.json b/frontend/package.json index 5fc8338..be58ffb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "vite build && node scripts/prerender.js", + "prerender": "node scripts/prerender.js", "lint": "eslint .", "preview": "vite preview", "test": "vitest run", @@ -30,6 +31,7 @@ "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.6.0", "jsdom": "^29.1.1", + "playwright": "^1.62.1", "tailwindcss": "^4.3.0", "vite": "^8.0.12", "vitest": "^4.1.10" diff --git a/frontend/scripts/prerender.js b/frontend/scripts/prerender.js new file mode 100644 index 0000000..3a8963b --- /dev/null +++ b/frontend/scripts/prerender.js @@ -0,0 +1,54 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { preview } from 'vite' +import { chromium } from 'playwright' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const root = join(__dirname, '..') +const distDir = join(root, 'dist') + +// Pages publiques, indexables (voir public/robots.txt) : ce sont les seules +// qui ont besoin d'un HTML pré-rendu pour le SEO et les aperçus sociaux. Les +// pages privées (profil, mes gains, admin...) restent en rendu 100% client, +// servies par le index.html générique via le fallback SPA de nginx. +const ROUTES = [ + '/accueil', + '/notre-concept', + '/regles-jeu', + '/contact', + '/mentions-legales', + '/confidentialite', + '/accessibilite', + '/cgu', + '/sitemap', + '/inscription', + '/connexion', +] + +async function main() { + const server = await preview({ root, preview: { port: 4173, strictPort: false } }) + const base = server.resolvedUrls.local[0].replace(/\/$/, '') + + const browser = await chromium.launch() + const page = await browser.newPage() + + for (const route of ROUTES) { + await page.goto(`${base}${route}`, { waitUntil: 'networkidle' }) + await page.waitForSelector('h1') + + const html = await page.content() + const outDir = join(distDir, route.replace(/^\//, '')) + mkdirSync(outDir, { recursive: true }) + writeFileSync(join(outDir, 'index.html'), `<!doctype html>\n${html}`) + console.log(`✓ pré-rendu ${route} -> dist${route}/index.html`) + } + + await browser.close() + await server.close() +} + +main().catch((error) => { + console.error('Erreur de pré-rendu :', error) + process.exit(1) +}) diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 9e40799..43380c7 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -1,6 +1,5 @@ import { useState, useEffect } from 'react' -import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi } from '../services/admin' -import { searchTicketApi, deliverClaimApi } from '../services/employee' +import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi, createEmployeeApi } from '../services/admin' import { useHandleAuthError } from '../hooks/useHandleAuthError' function StatCard({ label, value, sub }) { @@ -63,20 +62,17 @@ export default function Admin() { const [settingsError, setSettingsError] = useState('') const [settingsOk, setSettingsOk] = useState('') - // Remise de lot - const [ticketCode, setTicketCode] = useState('') - const [ticketResult, setTicketResult] = useState(null) - const [ticketSearching, setTicketSearching] = useState(false) - const [ticketDelivering, setTicketDelivering] = useState(false) - const [ticketSearchErr, setTicketSearchErr] = useState('') - const [ticketDeliverErr, setTicketDeliverErr] = useState('') - const [ticketSuccess, setTicketSuccess] = useState(false) - // Contacts emailing const [emailingContacts, setEmailingContacts] = useState(null) const [emailingLoading, setEmailingLoading] = useState(false) const [emailingError, setEmailingError] = useState('') + // Création d'un compte employé + const [employeeForm, setEmployeeForm] = useState({ firstname: '', lastname: '', email: '', password: '' }) + const [employeeSaving, setEmployeeSaving] = useState(false) + const [employeeError, setEmployeeError] = useState('') + const [employeeOk, setEmployeeOk] = useState('') + useEffect(() => { Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()]) .then(([s, u, w]) => { @@ -154,43 +150,19 @@ export default function Admin() { URL.revokeObjectURL(url) } - const handleTicketSearch = async (e) => { + const handleCreateEmployee = async (e) => { e.preventDefault() - setTicketResult(null) - setTicketSearchErr('') - setTicketDeliverErr('') - setTicketSuccess(false) - setTicketSearching(true) + setEmployeeError('') + setEmployeeOk('') + setEmployeeSaving(true) try { - const data = await searchTicketApi(ticketCode.trim()) - setTicketResult(data) + const data = await createEmployeeApi(employeeForm) + setEmployeeOk(`Compte employé créé pour ${data.employee.firstname} ${data.employee.lastname} (${data.employee.email}).`) + setEmployeeForm({ firstname: '', lastname: '', email: '', password: '' }) } catch (err) { - if (!handleAuthError(err)) setTicketSearchErr(err.message) + if (!handleAuthError(err)) setEmployeeError(err.message) } finally { - setTicketSearching(false) - } - } - - const handleTicketDeliver = async () => { - if (!ticketResult?.claim?.id) return - setTicketDeliverErr('') - setTicketDelivering(true) - try { - await deliverClaimApi(ticketResult.claim.id) - setTicketResult((prev) => ({ - ...prev, - claim: { ...prev.claim, status: 'DELIVERED', claimedAt: new Date().toISOString() }, - })) - setTicketSuccess(true) - setStats((prev) => prev ? { - ...prev, - claimsPending: Math.max(0, (prev.claimsPending ?? 1) - 1), - claimsDelivered: (prev.claimsDelivered ?? 0) + 1, - } : prev) - } catch (err) { - if (!handleAuthError(err)) setTicketDeliverErr(err.message) - } finally { - setTicketDelivering(false) + setEmployeeSaving(false) } } @@ -415,93 +387,73 @@ export default function Admin() { )} </div> - {/* ── Remise de lot ── */} + {/* ── Création d'un compte employé ── */} <div className="bg-white rounded-xl shadow p-4 sm:p-6"> - <h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Remise de lot</h2> + <h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Créer un compte employé</h2> + <p className="text-xs sm:text-sm text-gray-500 mb-4"> + Donne accès à l'espace employé (remise des lots en boutique). L'admin ne gère pas les remises lui-même. + </p> - <form onSubmit={handleTicketSearch} className="flex gap-2 mb-4"> - <label htmlFor="admin-ticket-code" className="sr-only">Code ticket</label> - <input - type="text" - id="admin-ticket-code" - value={ticketCode} - onChange={(e) => setTicketCode(e.target.value.toUpperCase())} - placeholder="Code ticket (10 caractères)" - maxLength={10} - className="flex-1 min-w-0 border border-gray-200 rounded-lg px-4 py-2.5 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]" - /> - <button - type="submit" - disabled={ticketSearching || ticketCode.trim().length === 0} - className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2.5 rounded-lg transition-colors text-sm whitespace-nowrap" - > - {ticketSearching ? 'Recherche…' : 'Rechercher'} - </button> - </form> - - {ticketSearchErr && ( - <p className="text-red-500 text-sm mb-3">{ticketSearchErr}</p> - )} - - {ticketResult && ( - <div className="border border-gray-100 rounded-xl overflow-hidden"> - {ticketSuccess && ( - <div className="bg-green-500 px-4 py-2.5 flex items-center gap-2"> - <svg className="w-4 h-4 text-white 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" /> - </svg> - <p className="text-white text-sm font-medium">Lot remis avec succès.</p> - </div> - )} - - <div className="px-4 py-3 flex items-start justify-between gap-3 border-b border-gray-100"> - <div> - <p className="font-semibold text-gray-900 text-sm">{ticketResult.lot.name}</p> - <p className="text-gray-500 text-xs mt-0.5">{ticketResult.lot.description}</p> - {ticketResult.lot.value && <p className="text-[#006B6B] text-xs font-semibold mt-1">{ticketResult.lot.value} €</p>} - </div> - <span className={`flex-shrink-0 text-xs font-semibold px-2.5 py-1 rounded-full ${ - ticketResult.claim?.status === 'DELIVERED' ? 'bg-green-100 text-green-700' - : ticketResult.claim?.status === 'CANCELLED' ? 'bg-gray-100 text-gray-500' - : 'bg-yellow-100 text-yellow-700' - }`}> - {ticketResult.claim?.status === 'DELIVERED' ? 'Remis' - : ticketResult.claim?.status === 'CANCELLED' ? 'Expiré' - : 'À remettre'} - </span> - </div> - - <div className="px-4 py-3 flex items-center gap-3 border-b border-gray-100"> - <div className="w-8 h-8 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-xs font-bold flex-shrink-0"> - {(ticketResult.client.firstname?.[0] ?? '?').toUpperCase()}{(ticketResult.client.lastname?.[0] ?? '?').toUpperCase()} - </div> - <div> - <p className="text-sm font-medium text-gray-800">{ticketResult.client.firstname} {ticketResult.client.lastname}</p> - <p className="text-xs text-gray-500">{ticketResult.client.email}</p> - </div> - </div> - - <div className="px-4 py-3 space-y-2"> - {ticketDeliverErr && <p className="text-red-500 text-xs">{ticketDeliverErr}</p>} - - {ticketResult.claim?.status === 'PENDING' && !ticketSuccess && ( - <button - onClick={handleTicketDeliver} - disabled={ticketDelivering} - className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold py-2.5 rounded-lg transition-colors text-sm" - > - {ticketDelivering ? 'Confirmation…' : 'Confirmer la remise du lot'} - </button> - )} - <button - onClick={() => { setTicketCode(''); setTicketResult(null); setTicketSuccess(false); setTicketSearchErr(''); setTicketDeliverErr('') }} - className="w-full border border-gray-200 text-gray-500 hover:bg-gray-50 py-2 rounded-lg transition-colors text-sm" - > - Rechercher un autre code - </button> - </div> + <form onSubmit={handleCreateEmployee} className="grid sm:grid-cols-2 gap-3"> + <div> + <label htmlFor="employee-firstname" className="block text-xs font-medium text-gray-700 mb-1">Prénom</label> + <input + type="text" + id="employee-firstname" + value={employeeForm.firstname} + onChange={(e) => setEmployeeForm({ ...employeeForm, firstname: e.target.value })} + required + className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" + /> </div> - )} + <div> + <label htmlFor="employee-lastname" className="block text-xs font-medium text-gray-700 mb-1">Nom</label> + <input + type="text" + id="employee-lastname" + value={employeeForm.lastname} + onChange={(e) => setEmployeeForm({ ...employeeForm, lastname: e.target.value })} + required + className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" + /> + </div> + <div> + <label htmlFor="employee-email" className="block text-xs font-medium text-gray-700 mb-1">Email</label> + <input + type="email" + id="employee-email" + value={employeeForm.email} + onChange={(e) => setEmployeeForm({ ...employeeForm, email: e.target.value })} + required + className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" + /> + </div> + <div> + <label htmlFor="employee-password" className="block text-xs font-medium text-gray-700 mb-1">Mot de passe</label> + <input + type="password" + id="employee-password" + value={employeeForm.password} + onChange={(e) => setEmployeeForm({ ...employeeForm, password: e.target.value })} + required + minLength={8} + placeholder="8 caractères minimum" + className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" + /> + </div> + + <div className="sm:col-span-2 flex items-center gap-3 pt-1"> + <button + type="submit" + disabled={employeeSaving} + className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm" + > + {employeeSaving ? 'Création…' : 'Créer le compte'} + </button> + {employeeError && <p className="text-red-500 text-xs sm:text-sm">{employeeError}</p>} + {employeeOk && <p className="text-green-600 text-xs sm:text-sm">{employeeOk}</p>} + </div> + </form> </div> {/* ── Tirage au sort ── */} diff --git a/frontend/src/pages/AuthCallback.jsx b/frontend/src/pages/AuthCallback.jsx index 574b4ae..3cb997f 100644 --- a/frontend/src/pages/AuthCallback.jsx +++ b/frontend/src/pages/AuthCallback.jsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useRef } from 'react' import { useNavigate, useSearchParams, Link } from 'react-router-dom' import { useAuth } from '../context/AuthContext' @@ -8,39 +8,40 @@ 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) + // L'erreur (s'il y en a une) est entièrement dérivée de l'URL : pas besoin + // d'un state ni d'un effet pour la calculer, juste pour l'action à usage + // unique (login + redirection) une fois l'utilisateur décodé avec succès. + const errorParam = searchParams.get('error') + const token = searchParams.get('token') + const encodedUser = searchParams.get('user') + + let error = '' + let user = null + if (errorParam) { + error = errorParam + } else if (!token || !encodedUser) { + error = 'Réponse de connexion incomplète.' + } else { + try { + user = JSON.parse(atob(encodedUser)) + } catch { + error = 'Réponse de connexion illisible.' + } + } + useEffect(() => { - if (handled.current) return + if (handled.current || !user) return handled.current = true - const errorParam = searchParams.get('error') - if (errorParam) { - setError(errorParam) - return - } + login(user, token) - 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 (user.role === 'ADMIN') navigate('/admin', { replace: true }) + else if (user.role === 'EMPLOYEE') navigate('/employe', { replace: true }) + else navigate('/mes-gains', { replace: true }) + }, [user, token, login, navigate]) if (error) { return ( diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx index f54c3e0..acfe59f 100644 --- a/frontend/src/pages/Profile.jsx +++ b/frontend/src/pages/Profile.jsx @@ -41,7 +41,7 @@ export default function Profile() { .catch((e) => { if (!handleAuthError(e)) setHasPassword(true) }) - }, []) + }, [handleAuthError]) const handleSendSetupLink = async () => { setLinkError('') diff --git a/frontend/src/services/admin.js b/frontend/src/services/admin.js index 2e6a3ba..1228ce3 100644 --- a/frontend/src/services/admin.js +++ b/frontend/src/services/admin.js @@ -60,3 +60,14 @@ export async function getWinnerApi() { if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status) return data } + +export async function createEmployeeApi({ firstname, lastname, email, password }) { + const res = await fetch(`${BASE}/employees`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ firstname, lastname, email, password }), + }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || "Erreur lors de la création de l'employé.", res.status) + return data +} From 8a6355cde1349ecfef5c5039fcaeb5d3a0efb445 Mon Sep 17 00:00:00 2001 From: Emma781227 <emmaengongomo5@gmail.com> Date: Mon, 14 Sep 2026 21:08:04 +0200 Subject: [PATCH 10/11] ajout de la connection via facebook et du captcha --- Jenkinsfile | 1 + backend/.env.example | 5 + backend/jest.config.js | 2 + backend/package-lock.json | 13 ++ backend/package.json | 1 + backend/src/config/passport.js | 186 +++++++++++------- backend/src/controllers/auth.controller.js | 11 +- backend/src/middlewares/captcha.middleware.js | 34 ++++ backend/src/routes/auth.routes.js | 85 ++++++-- backend/src/utils/frontendUrl.js | 9 + backend/tests/captcha.middleware.test.js | 75 +++++++ backend/tests/facebookLogin.test.js | 113 +++++++++++ backend/tests/oauthCallback.test.js | 64 ++++++ backend/tests/setupEnv.js | 4 + deploy/dev/docker-compose.yml | 2 + deploy/preprod/docker-compose.yml | 2 + deploy/prod/docker-compose.yml | 2 + frontend/.gitignore | 1 + frontend/Dockerfile | 3 + frontend/package-lock.json | 60 +++--- frontend/package.json | 1 + frontend/src/components/Captcha.jsx | 19 ++ frontend/src/config/captcha.js | 8 + frontend/src/pages/AuthCallback.jsx | 5 +- frontend/src/pages/Connexion.jsx | 28 ++- frontend/src/pages/Inscription.jsx | 24 ++- frontend/src/services/auth.js | 6 +- .../src/test/fonctionnel/Connexion.test.jsx | 3 +- frontend/vite.config.js | 3 + 29 files changed, 617 insertions(+), 153 deletions(-) create mode 100644 backend/src/middlewares/captcha.middleware.js create mode 100644 backend/src/utils/frontendUrl.js create mode 100644 backend/tests/captcha.middleware.test.js create mode 100644 backend/tests/facebookLogin.test.js create mode 100644 backend/tests/oauthCallback.test.js create mode 100644 backend/tests/setupEnv.js create mode 100644 frontend/src/components/Captcha.jsx create mode 100644 frontend/src/config/captcha.js diff --git a/Jenkinsfile b/Jenkinsfile index b7b2a71..ec4d929 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -229,6 +229,7 @@ pipeline { docker build \ --build-arg VITE_API_URL="${FRONTEND_API_URL}" \ + --build-arg VITE_TURNSTILE_SITE_KEY="${TURNSTILE_SITE_KEY:-}" \ -t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \ frontend ''' diff --git a/backend/.env.example b/backend/.env.example index 47c457f..e434a0f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,3 +19,8 @@ GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" FACEBOOK_APP_ID="" FACEBOOK_APP_SECRET="" + +# Cloudflare Turnstile (anti-robot sur inscription et connexion). Laisser vide +# désactive la vérification. La clé publique va dans frontend/.env +# (VITE_TURNSTILE_SITE_KEY). +TURNSTILE_SECRET_KEY="" diff --git a/backend/jest.config.js b/backend/jest.config.js index b803d50..bbaefc1 100644 --- a/backend/jest.config.js +++ b/backend/jest.config.js @@ -3,6 +3,8 @@ module.exports = { testMatch: ["**/tests/**/*.test.js"], + setupFiles: ["<rootDir>/tests/setupEnv.js"], + clearMocks: true, testTimeout: 10000, diff --git a/backend/package-lock.json b/backend/package-lock.json index 45cd3e0..515ad9d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -18,6 +18,7 @@ "jsonwebtoken": "^9.0.3", "nodemailer": "^9.0.3", "passport": "^0.7.0", + "passport-facebook": "^3.0.0", "passport-google-oauth20": "^2.0.0", "prisma": "^6.19.3", "swagger-jsdoc": "^6.3.0", @@ -5823,6 +5824,18 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-facebook": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/passport-facebook/-/passport-facebook-3.0.0.tgz", + "integrity": "sha512-K/qNzuFsFISYAyC1Nma4qgY/12V3RSLFdFVsPKXiKZt434wOvthFW1p7zKa1iQihQMRhaWorVE1o3Vi1o+ZgeQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/passport-google-oauth20": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", diff --git a/backend/package.json b/backend/package.json index a637c62..b9569c0 100644 --- a/backend/package.json +++ b/backend/package.json @@ -25,6 +25,7 @@ "jsonwebtoken": "^9.0.3", "nodemailer": "^9.0.3", "passport": "^0.7.0", + "passport-facebook": "^3.0.0", "passport-google-oauth20": "^2.0.0", "prisma": "^6.19.3", "swagger-jsdoc": "^6.3.0", diff --git a/backend/src/config/passport.js b/backend/src/config/passport.js index 1e9a434..b9e2903 100644 --- a/backend/src/config/passport.js +++ b/backend/src/config/passport.js @@ -1,17 +1,21 @@ const passport = require("passport"); const GoogleStrategy = require("passport-google-oauth20").Strategy; +const FacebookStrategy = require("passport-facebook").Strategy; const crypto = require("crypto"); const prisma = require("./prisma"); const transporter = require("./mailer"); +const getFrontendUrl = require("../utils/frontendUrl"); + +const PROVIDER_LABELS = { GOOGLE: "Google", FACEBOOK: "Facebook" }; // 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 +// réinitialisation : sans mot de passe, l'utilisateur dépend entièrement du +// provider 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) { +async function sendPasswordSetupEmail(user, providerLabel) { const token = crypto.randomBytes(32).toString("hex"); await prisma.user.update({ @@ -22,8 +26,7 @@ async function sendPasswordSetupEmail(user) { }, }); - const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; - const setupUrl = `${frontendUrl}/definir-mot-de-passe/${token}`; + const setupUrl = `${getFrontendUrl()}/definir-mot-de-passe/${token}`; await transporter.sendMail({ from: process.env.MAIL_FROM, @@ -32,9 +35,9 @@ async function sendPasswordSetupEmail(user) { 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 + <p>Votre compte a été créé avec ${providerLabel}. 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> + également à votre compte sans passer par ${providerLabel}.</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 @@ -42,95 +45,126 @@ async function sendPasswordSetupEmail(user) { </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> + connexion ${providerLabel} continuera de fonctionner.</p> </div> `, }); } +// Callback de vérification commun à tous les providers : passport normalise +// le profil (id, name, emails, photos), seule la valeur de `provider` change. +function verifyOAuthProfile(provider) { + const providerLabel = PROVIDER_LABELS[provider]; + + return async (accessToken, refreshToken, profile, done) => { + try { + const email = profile.emails?.[0]?.value; + + // Cas fréquent chez Facebook : compte ouvert avec un numéro de téléphone. + if (!email) { + return done(null, false, { + message: `Aucune adresse email n'est associée à ce compte ${providerLabel}.`, + }); + } + + // 1. Compte déjà lié à ce compte externe : connexion directe. + const byProvider = await prisma.user.findUnique({ + where: { + provider_providerId: { provider, providerId: profile.id }, + }, + }); + + if (byProvider) return done(null, byProvider); + + // 2. Un compte existe avec le même email : on le rattache au provider. + // L'email étant déjà validé par le provider, 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, + 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, + providerId: profile.id, + avatarUrl: profile.photos?.[0]?.value || null, + isEmailVerified: true, + }, + }); + + // 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, providerLabel); + } catch (mailError) { + console.error("Erreur envoi email de definition du mot de passe:", mailError); + } + + return done(null, created); + } catch (error) { + return done(error); + } + }; +} + // Authentification stateless : le callback OAuth émet directement notre JWT, // donc pas de session Express ni de serialize/deserialize à brancher. +const apiUrl = process.env.APP_URL || "http://localhost:5000"; + const googleEnabled = Boolean(process.env.GOOGLE_CLIENT_ID) && Boolean(process.env.GOOGLE_CLIENT_SECRET); +const facebookEnabled = + Boolean(process.env.FACEBOOK_APP_ID) && Boolean(process.env.FACEBOOK_APP_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`, + callbackURL: `${apiUrl}/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, - }, - }); - - // 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); - } - } + verifyOAuthProfile("GOOGLE") ) ); } -module.exports = { passport, googleEnabled }; +if (facebookEnabled) { + passport.use( + new FacebookStrategy( + { + clientID: process.env.FACEBOOK_APP_ID, + clientSecret: process.env.FACEBOOK_APP_SECRET, + callbackURL: `${apiUrl}/api/auth/facebook/callback`, + // Sans profileFields, la Graph API ne renvoie que l'id et le nom. + profileFields: ["id", "displayName", "name", "emails", "photos"], + // appsecret_proof : empêche l'usage d'un access token volé ailleurs. + enableProof: true, + }, + verifyOAuthProfile("FACEBOOK") + ) + ); +} + +module.exports = { passport, googleEnabled, facebookEnabled, verifyOAuthProfile }; diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index cef2552..cfc12bb 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -3,6 +3,7 @@ const jwt = require("jsonwebtoken"); const crypto = require("crypto"); const prisma = require("../config/prisma"); const transporter = require("../config/mailer"); +const getFrontendUrl = require("../utils/frontendUrl"); const register = async (req, res) => { try { @@ -254,7 +255,7 @@ const forgotPassword = async (req, res) => { // 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 frontendUrl = getFrontendUrl(); const resetUrl = `${frontendUrl}/definir-mot-de-passe/${resetPasswordToken}`; await transporter.sendMail({ @@ -397,7 +398,7 @@ const verifyCode = async (req, res) => { // 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"; + const frontendUrl = getFrontendUrl(); try { const user = req.user; @@ -426,7 +427,11 @@ const oauthCallback = (req, res) => { }) ).toString("base64"); - return res.redirect(`${frontendUrl}/auth/callback?token=${token}&user=${payload}`); + // encodeURIComponent : le base64 peut contenir des « + » que le front + // relirait comme des espaces dans la query string. + return res.redirect( + `${frontendUrl}/auth/callback?token=${token}&user=${encodeURIComponent(payload)}` + ); } catch (error) { console.error("Erreur oauthCallback:", error); return res.redirect( diff --git a/backend/src/middlewares/captcha.middleware.js b/backend/src/middlewares/captcha.middleware.js new file mode 100644 index 0000000..8fe7eb3 --- /dev/null +++ b/backend/src/middlewares/captcha.middleware.js @@ -0,0 +1,34 @@ +const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + +// Vérifie le jeton Cloudflare Turnstile envoyé par le front dans +// `captchaToken`. Sans TURNSTILE_SECRET_KEY, la vérification est ignorée +// (même logique que googleEnabled dans config/passport.js). +// À placer AVANT validate() : zod retire les clés inconnues de req.body. +const verifyCaptcha = async (req, res, next) => { + const secret = process.env.TURNSTILE_SECRET_KEY; + if (!secret) return next(); + + const token = req.body?.captchaToken; + if (!token) { + return res.status(400).json({ message: "Veuillez confirmer que vous n'êtes pas un robot." }); + } + + try { + const response = await fetch(VERIFY_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ secret, response: token, remoteip: req.ip }), + }); + const data = await response.json(); + + if (!data.success) { + return res.status(400).json({ message: "Vérification anti-robot échouée, veuillez réessayer." }); + } + return next(); + } catch (error) { + console.error("Erreur vérification Turnstile :", error); + return res.status(503).json({ message: "Service de vérification indisponible, réessayez plus tard." }); + } +}; + +module.exports = verifyCaptcha; diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index c697f00..33cf984 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -2,8 +2,10 @@ const express = require("express"); const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller"); const authMiddleware = require("../middlewares/auth.middleware"); const validate = require("../middlewares/validate.middleware"); +const verifyCaptcha = require("../middlewares/captcha.middleware"); const { registerSchema, loginSchema } = require("../validations/auth.validation"); -const { passport, googleEnabled } = require("../config/passport"); +const { passport, googleEnabled, facebookEnabled } = require("../config/passport"); +const getFrontendUrl = require("../utils/frontendUrl"); const router = express.Router(); @@ -33,7 +35,7 @@ const router = express.Router(); * 201: * description: Utilisateur créé */ -router.post("/register", validate(registerSchema), register); +router.post("/register", verifyCaptcha, validate(registerSchema), register); /** * @swagger @@ -49,7 +51,7 @@ router.post("/register", validate(registerSchema), register); * description: Connexion réussie */ -router.post("/login", validate(loginSchema), login); +router.post("/login", verifyCaptcha, validate(loginSchema), login); /** * @swagger @@ -187,30 +189,73 @@ router.delete("/account", authMiddleware, deleteAccount); * 302: * description: Redirection vers FRONTEND_URL/auth/callback avec le token. */ -if (googleEnabled) { - router.get( - "/google", - passport.authenticate("google", { scope: ["profile", "email"], session: false }) - ); + +/** + * @swagger + * /api/auth/facebook: + * get: + * summary: Démarre la connexion via Facebook (redirection OAuth 2.0) + * tags: + * - Auth + * responses: + * 302: + * description: Redirection vers l'écran de consentement Facebook. + * 503: + * description: Connexion Facebook non configurée sur ce serveur. + */ + +/** + * @swagger + * /api/auth/facebook/callback: + * get: + * summary: Retour de Facebook, émet le JWT et redirige vers le front + * tags: + * - Auth + * responses: + * 302: + * description: Redirection vers FRONTEND_URL/auth/callback avec le token. + */ + +function mountOAuthRoutes({ path, strategy, label, enabled, scope }) { + if (!enabled) { + // 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 ${label} n'est pas configurée sur ce serveur.` }); + + router.get(path, notConfigured); + router.get(`${path}/callback`, notConfigured); + return; + } + + router.get(path, passport.authenticate(strategy, { scope, session: false })); router.get( - "/google/callback", - passport.authenticate("google", { + `${path}/callback`, + passport.authenticate(strategy, { session: false, - failureRedirect: `${process.env.FRONTEND_URL || "http://localhost:5173"}/auth/callback?error=${encodeURIComponent( - "Connexion Google refusée." + failureRedirect: `${getFrontendUrl()}/auth/callback?error=${encodeURIComponent( + `Connexion ${label} 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); } +mountOAuthRoutes({ + path: "/google", + strategy: "google", + label: "Google", + enabled: googleEnabled, + scope: ["profile", "email"], +}); + +mountOAuthRoutes({ + path: "/facebook", + strategy: "facebook", + label: "Facebook", + enabled: facebookEnabled, + scope: ["email"], +}); + module.exports = router; \ No newline at end of file diff --git a/backend/src/utils/frontendUrl.js b/backend/src/utils/frontendUrl.js new file mode 100644 index 0000000..3d0c3da --- /dev/null +++ b/backend/src/utils/frontendUrl.js @@ -0,0 +1,9 @@ +// FRONTEND_URL peut lister plusieurs origines séparées par des virgules (prod : +// domaine nu + www), ce qui sert à CORS dans app.js. Pour construire un lien +// (email, redirection OAuth), on ne garde que la première, l'URL canonique. +function getFrontendUrl() { + const first = (process.env.FRONTEND_URL || "").split(",")[0].trim(); + return (first || "http://localhost:5173").replace(/\/+$/, ""); +} + +module.exports = getFrontendUrl; diff --git a/backend/tests/captcha.middleware.test.js b/backend/tests/captcha.middleware.test.js new file mode 100644 index 0000000..e9539d9 --- /dev/null +++ b/backend/tests/captcha.middleware.test.js @@ -0,0 +1,75 @@ +const verifyCaptcha = require("../src/middlewares/captcha.middleware"); + +function mockRes() { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +describe("verifyCaptcha", () => { + const originalFetch = global.fetch; + + beforeEach(() => { + process.env.TURNSTILE_SECRET_KEY = "secret-test"; + global.fetch = jest.fn(); + }); + + afterEach(() => { + process.env.TURNSTILE_SECRET_KEY = ""; + global.fetch = originalFetch; + }); + + it("laisse passer sans vérifier quand aucune clé n'est configurée", async () => { + process.env.TURNSTILE_SECRET_KEY = ""; + const next = jest.fn(); + + await verifyCaptcha({ body: {} }, mockRes(), next); + + expect(next).toHaveBeenCalled(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("refuse une requête sans jeton", async () => { + const res = mockRes(); + const next = jest.fn(); + + await verifyCaptcha({ body: {} }, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(next).not.toHaveBeenCalled(); + }); + + it("appelle next quand Cloudflare valide le jeton", async () => { + global.fetch.mockResolvedValue({ json: () => Promise.resolve({ success: true }) }); + const next = jest.fn(); + + await verifyCaptcha({ body: { captchaToken: "jeton" }, ip: "1.2.3.4" }, mockRes(), next); + + expect(next).toHaveBeenCalled(); + const [, options] = global.fetch.mock.calls[0]; + expect(JSON.parse(options.body)).toEqual({ secret: "secret-test", response: "jeton", remoteip: "1.2.3.4" }); + }); + + it("refuse quand Cloudflare rejette le jeton", async () => { + global.fetch.mockResolvedValue({ json: () => Promise.resolve({ success: false }) }); + const res = mockRes(); + const next = jest.fn(); + + await verifyCaptcha({ body: { captchaToken: "faux" } }, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(next).not.toHaveBeenCalled(); + }); + + it("répond 503 quand Cloudflare est injoignable", async () => { + global.fetch.mockRejectedValue(new Error("réseau")); + jest.spyOn(console, "error").mockImplementation(() => {}); + const res = mockRes(); + + await verifyCaptcha({ body: { captchaToken: "jeton" } }, res, jest.fn()); + + expect(res.status).toHaveBeenCalledWith(503); + console.error.mockRestore(); + }); +}); diff --git a/backend/tests/facebookLogin.test.js b/backend/tests/facebookLogin.test.js new file mode 100644 index 0000000..ebc094f --- /dev/null +++ b/backend/tests/facebookLogin.test.js @@ -0,0 +1,113 @@ +// Identifiants factices posés avant le chargement de passport : la stratégie +// Facebook est ainsi enregistrée même sans vraie app Meta configurée. +process.env.FACEBOOK_APP_ID = "test-app-id"; +process.env.FACEBOOK_APP_SECRET = "test-app-secret"; + +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const mailer = require("../src/config/mailer"); +const { passport } = require("../src/config/passport"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() })); + +const EMAIL = "facebook-login-test@test.local"; +const EMAIL_EXISTANT = "facebook-link-test@test.local"; + +function profil(overrides = {}) { + return { + id: "sim-facebook-id-987654", + displayName: "Léa Durand", + name: { givenName: "Léa", familyName: "Durand" }, + emails: [{ value: EMAIL }], + photos: [{ value: "https://example.test/fb.png" }], + ...overrides, + }; +} + +// Rejoue le callback de la strategie Facebook sans sortir sur le reseau. +function verifierProfilFacebook(profile) { + const strategy = passport._strategy("facebook"); + return new Promise((resolve, reject) => { + strategy._verify("access-token", "refresh-token", profile, (err, user, info) => { + if (err) reject(err); + else resolve({ user, info }); + }); + }); +} + +async function cleanup() { + await prisma.user.deleteMany({ where: { email: { in: [EMAIL, EMAIL_EXISTANT] } } }); +} + +beforeAll(cleanup); +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Connexion Facebook", () => { + beforeEach(() => jest.clearAllMocks()); + + test("la route de depart redirige vers Facebook", async () => { + const res = await request(app).get("/api/auth/facebook"); + + expect(res.status).toBe(302); + const url = new URL(res.headers.location); + expect(url.hostname).toBe("www.facebook.com"); + expect(url.searchParams.get("client_id")).toBe("test-app-id"); + expect(url.searchParams.get("scope")).toBe("email"); + expect(url.searchParams.get("redirect_uri")).toMatch(/\/api\/auth\/facebook\/callback$/); + }); + + test("un premier passage cree un compte FACEBOOK sans mot de passe", async () => { + mailer.sendMail.mockResolvedValue(true); + + const { user } = await verifierProfilFacebook(profil()); + + expect(user.provider).toBe("FACEBOOK"); + expect(user.providerId).toBe("sim-facebook-id-987654"); + expect(user.password).toBeNull(); + expect(user.isEmailVerified).toBe(true); + expect(user.firstname).toBe("Léa"); + + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + expect(mailer.sendMail.mock.calls[0][0].html).toContain("créé avec Facebook"); + }); + + test("un second passage reconnait le compte sans renvoyer d email", async () => { + const { user } = await verifierProfilFacebook(profil()); + + expect(user.email).toBe(EMAIL); + expect(mailer.sendMail).not.toHaveBeenCalled(); + }); + + test("un compte existant avec le meme email est rattache a Facebook", async () => { + await prisma.user.create({ + data: { firstname: "Paul", lastname: "Martin", email: EMAIL_EXISTANT, password: "hash", isEmailVerified: false }, + }); + + const { user } = await verifierProfilFacebook( + profil({ id: "sim-facebook-id-link", emails: [{ value: EMAIL_EXISTANT }] }) + ); + + expect(user.provider).toBe("FACEBOOK"); + expect(user.isEmailVerified).toBe(true); + // Le mot de passe existant est conserve. + expect(user.password).toBe("hash"); + }); + + test("un compte Facebook sans email est refuse", async () => { + const { user, info } = await verifierProfilFacebook(profil({ id: "sim-sans-email", emails: undefined })); + + expect(user).toBe(false); + expect(info.message).toMatch(/Facebook/); + }); + + test("la connexion par mot de passe oriente vers le bouton Facebook", 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 Facebook/); + }); +}); diff --git a/backend/tests/oauthCallback.test.js b/backend/tests/oauthCallback.test.js new file mode 100644 index 0000000..334a63b --- /dev/null +++ b/backend/tests/oauthCallback.test.js @@ -0,0 +1,64 @@ +// Ce fichier ne charge pas app.js, donc pas le .env : le JWT a besoin d'un secret. +process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret"; + +const getFrontendUrl = require("../src/utils/frontendUrl"); +const { oauthCallback } = require("../src/controllers/auth.controller"); + +describe("getFrontendUrl", () => { + const original = process.env.FRONTEND_URL; + afterEach(() => { + process.env.FRONTEND_URL = original; + }); + + test("ne garde que la premiere origine d une liste (CORS prod)", () => { + process.env.FRONTEND_URL = "https://site.fr, https://www.site.fr"; + expect(getFrontendUrl()).toBe("https://site.fr"); + }); + + test("retire le slash final", () => { + process.env.FRONTEND_URL = "https://site.fr/"; + expect(getFrontendUrl()).toBe("https://site.fr"); + }); + + test("retombe sur localhost sans configuration", () => { + delete process.env.FRONTEND_URL; + expect(getFrontendUrl()).toBe("http://localhost:5173"); + }); +}); + +describe("oauthCallback", () => { + const original = process.env.FRONTEND_URL; + afterEach(() => { + process.env.FRONTEND_URL = original; + }); + + test("redirige vers une URL valide avec un profil lisible par le front", () => { + process.env.FRONTEND_URL = "https://site.fr,https://www.site.fr"; + const res = { redirect: jest.fn() }; + // Accents (UTF-8) et « ~ » : ce profil produit un base64 contenant « + », + // qui deviendrait un espace sans encodage. + const user = { id: 7, firstname: "Chloé", lastname: "Lefèvre", email: "ab~test@test.local", role: "CLIENT" }; + expect(Buffer.from(JSON.stringify(user)).toString("base64")).toContain("+"); + + oauthCallback({ user }, res); + + const url = new URL(res.redirect.mock.calls[0][0]); + expect(`${url.origin}${url.pathname}`).toBe("https://site.fr/auth/callback"); + expect(url.searchParams.get("token")).toBeTruthy(); + + // Même décodage que frontend/src/pages/AuthCallback.jsx. + const bytes = Uint8Array.from(atob(url.searchParams.get("user")), (c) => c.charCodeAt(0)); + expect(JSON.parse(new TextDecoder().decode(bytes))).toEqual(user); + }); + + test("renvoie une erreur au front quand passport n a pas d utilisateur", () => { + process.env.FRONTEND_URL = "https://site.fr,https://www.site.fr"; + const res = { redirect: jest.fn() }; + + oauthCallback({}, res); + + const url = new URL(res.redirect.mock.calls[0][0]); + expect(url.origin).toBe("https://site.fr"); + expect(url.searchParams.get("error")).toMatch(/refusée/); + }); +}); diff --git a/backend/tests/setupEnv.js b/backend/tests/setupEnv.js new file mode 100644 index 0000000..b55f9ca --- /dev/null +++ b/backend/tests/setupEnv.js @@ -0,0 +1,4 @@ +// Exécuté avant chaque fichier de test, donc avant le dotenv de src/app.js : +// dotenv n'écrase pas une variable déjà définie, le captcha reste désactivé +// pour les tests d'API (captcha.middleware.test.js le teste à part). +process.env.TURNSTILE_SECRET_KEY = ""; diff --git a/deploy/dev/docker-compose.yml b/deploy/dev/docker-compose.yml index b1aaef0..f9ec95f 100644 --- a/deploy/dev/docker-compose.yml +++ b/deploy/dev/docker-compose.yml @@ -46,6 +46,8 @@ services: GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}" FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}" FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}" + # Vide, la vérification anti-robot Turnstile est désactivée. + TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY:-}" command: > sh -c " diff --git a/deploy/preprod/docker-compose.yml b/deploy/preprod/docker-compose.yml index 2729f5e..04ff8b8 100644 --- a/deploy/preprod/docker-compose.yml +++ b/deploy/preprod/docker-compose.yml @@ -46,6 +46,8 @@ services: GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}" FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}" FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}" + # Vide, la vérification anti-robot Turnstile est désactivée. + TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY:-}" command: > sh -c " diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml index 8cfb3a7..32a1a90 100644 --- a/deploy/prod/docker-compose.yml +++ b/deploy/prod/docker-compose.yml @@ -46,6 +46,8 @@ services: GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}" FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}" FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}" + # Vide, la vérification anti-robot Turnstile est désactivée. + TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY:-}" command: > sh -c " diff --git a/frontend/.gitignore b/frontend/.gitignore index a547bf3..438657a 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.env # Editor directories and files .vscode/* diff --git a/frontend/Dockerfile b/frontend/Dockerfile index f5f20fa..9aa2373 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3da5c2b..ffd9a20 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" } diff --git a/frontend/package.json b/frontend/package.json index be58ffb..9bfff8f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/components/Captcha.jsx b/frontend/src/components/Captcha.jsx new file mode 100644 index 0000000..c56e189 --- /dev/null +++ b/frontend/src/components/Captcha.jsx @@ -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('')} + /> + ) +} diff --git a/frontend/src/config/captcha.js b/frontend/src/config/captcha.js new file mode 100644 index 0000000..844ce45 --- /dev/null +++ b/frontend/src/config/captcha.js @@ -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) diff --git a/frontend/src/pages/AuthCallback.jsx b/frontend/src/pages/AuthCallback.jsx index 3cb997f..16f6e7f 100644 --- a/frontend/src/pages/AuthCallback.jsx +++ b/frontend/src/pages/AuthCallback.jsx @@ -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.' } diff --git a/frontend/src/pages/Connexion.jsx b/frontend/src/pages/Connexion.jsx index fc7adbd..9d3613e 100644 --- a/frontend/src/pages/Connexion.jsx +++ b/frontend/src/pages/Connexion.jsx @@ -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" /> diff --git a/frontend/src/pages/Inscription.jsx b/frontend/src/pages/Inscription.jsx index 70b76b6..d4df572 100644 --- a/frontend/src/pages/Inscription.jsx +++ b/frontend/src/pages/Inscription.jsx @@ -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" /> diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js index 43735b0..7537c9c 100644 --- a/frontend/src/services/auth.js +++ b/frontend/src/services/auth.js @@ -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() diff --git a/frontend/src/test/fonctionnel/Connexion.test.jsx b/frontend/src/test/fonctionnel/Connexion.test.jsx index 3fb3d99..0255e3b 100644 --- a/frontend/src/test/fonctionnel/Connexion.test.jsx +++ b/frontend/src/test/fonctionnel/Connexion.test.jsx @@ -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 () => { diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 8ba6a01..90758fd 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -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: '' }, }, }) From 2201446ee6964da114a28d479b36249a6c4042c1 Mon Sep 17 00:00:00 2001 From: Emma781227 <emmaengongomo5@gmail.com> Date: Mon, 14 Sep 2026 22:30:40 +0200 Subject: [PATCH 11/11] changement --- .../migration.sql | 2 + .../migration.sql | 14 + backend/prisma/schema.prisma | 5 + backend/prisma/seed.js | 5 +- backend/src/config/passport.js | 3 +- backend/src/controllers/admin.controller.js | 196 +++++++++++- backend/src/controllers/auth.controller.js | 162 +++++++--- backend/src/controllers/game.controller.js | 13 + backend/src/middlewares/auth.middleware.js | 14 + backend/src/routes/admin.routes.js | 70 +++++ backend/src/routes/auth.routes.js | 57 +++- backend/src/routes/game.routes.js | 15 +- backend/src/utils/gameWindow.js | 6 + backend/src/validations/admin.validation.js | 5 +- backend/src/validations/auth.validation.js | 30 +- backend/src/validations/email.validation.js | 11 + .../src/validations/password.validation.js | 21 ++ backend/tests/adminDeliveries.test.js | 144 +++++++++ backend/tests/e2e.flow.test.js | 23 +- backend/tests/employeeOnboarding.test.js | 166 ++++++++++ backend/tests/forgotPassword.test.js | 167 ++++++++++ backend/tests/gamePhases.test.js | 11 + backend/tests/googlePassword.test.js | 25 +- backend/tests/register.mail.test.js | 3 +- backend/tests/registerPassword.test.js | 62 ++++ frontend/src/App.jsx | 4 + frontend/src/components/PasswordRules.jsx | 21 ++ frontend/src/components/ProtectedRoute.jsx | 10 +- .../components/admin/EmployeeDeliveries.jsx | 288 ++++++++++++++++++ frontend/src/config/roles.js | 10 + frontend/src/hooks/useGameDates.js | 26 ++ frontend/src/pages/Accueil.jsx | 31 +- frontend/src/pages/Admin.jsx | 61 ++-- frontend/src/pages/AuthCallback.jsx | 6 +- frontend/src/pages/Connexion.jsx | 15 +- frontend/src/pages/DefinirMotDePasse.jsx | 25 +- frontend/src/pages/Inscription.jsx | 29 ++ frontend/src/pages/MotDePasseOublie.jsx | 117 +++++++ frontend/src/pages/NouveauMotDePasse.jsx | 139 +++++++++ frontend/src/pages/Profile.jsx | 15 +- frontend/src/services/admin.js | 27 +- frontend/src/services/auth.js | 33 +- frontend/src/services/game.js | 8 + frontend/src/setupTests.js | 3 + .../src/test/fonctionnel/Accueil.test.jsx | 41 +++ .../fonctionnel/DefinirMotDePasse.test.jsx | 56 ++++ .../fonctionnel/EmployeeDeliveries.test.jsx | 97 ++++++ .../src/test/fonctionnel/Inscription.test.jsx | 70 ++++- .../fonctionnel/MotDePasseOublie.test.jsx | 51 ++++ .../fonctionnel/NouveauMotDePasse.test.jsx | 74 +++++ .../test/fonctionnel/ProtectedRoute.test.jsx | 16 + frontend/src/test/unit/gameDates.test.js | 37 +++ frontend/src/utils/gameDates.js | 80 +++++ frontend/src/utils/passwordRules.js | 14 + 54 files changed, 2495 insertions(+), 139 deletions(-) create mode 100644 backend/prisma/migrations/20260914193115_add_must_change_password/migration.sql create mode 100644 backend/prisma/migrations/20260914220000_normalize_user_emails/migration.sql create mode 100644 backend/src/validations/email.validation.js create mode 100644 backend/src/validations/password.validation.js create mode 100644 backend/tests/adminDeliveries.test.js create mode 100644 backend/tests/employeeOnboarding.test.js create mode 100644 backend/tests/forgotPassword.test.js create mode 100644 backend/tests/registerPassword.test.js create mode 100644 frontend/src/components/PasswordRules.jsx create mode 100644 frontend/src/components/admin/EmployeeDeliveries.jsx create mode 100644 frontend/src/config/roles.js create mode 100644 frontend/src/hooks/useGameDates.js create mode 100644 frontend/src/pages/MotDePasseOublie.jsx create mode 100644 frontend/src/pages/NouveauMotDePasse.jsx create mode 100644 frontend/src/test/fonctionnel/Accueil.test.jsx create mode 100644 frontend/src/test/fonctionnel/DefinirMotDePasse.test.jsx create mode 100644 frontend/src/test/fonctionnel/EmployeeDeliveries.test.jsx create mode 100644 frontend/src/test/fonctionnel/MotDePasseOublie.test.jsx create mode 100644 frontend/src/test/fonctionnel/NouveauMotDePasse.test.jsx create mode 100644 frontend/src/test/unit/gameDates.test.js create mode 100644 frontend/src/utils/gameDates.js create mode 100644 frontend/src/utils/passwordRules.js diff --git a/backend/prisma/migrations/20260914193115_add_must_change_password/migration.sql b/backend/prisma/migrations/20260914193115_add_must_change_password/migration.sql new file mode 100644 index 0000000..c25e8e3 --- /dev/null +++ b/backend/prisma/migrations/20260914193115_add_must_change_password/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "mustChangePassword" BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/migrations/20260914220000_normalize_user_emails/migration.sql b/backend/prisma/migrations/20260914220000_normalize_user_emails/migration.sql new file mode 100644 index 0000000..d8c051e --- /dev/null +++ b/backend/prisma/migrations/20260914220000_normalize_user_emails/migration.sql @@ -0,0 +1,14 @@ +-- Les emails sont désormais stockés et recherchés en minuscules, sans espaces +-- autour (voir src/validations/email.validation.js). On aligne les comptes +-- existants, sauf ceux dont la version normalisée appartient déjà à un autre +-- compte : les modifier violerait la contrainte d'unicité et ferait échouer le +-- déploiement. Ces doublons éventuels sont à traiter à la main. +UPDATE "User" AS u +SET "email" = lower(trim(u."email")) +WHERE u."email" <> lower(trim(u."email")) + AND NOT EXISTS ( + SELECT 1 + FROM "User" AS other + WHERE other."id" <> u."id" + AND lower(trim(other."email")) = lower(trim(u."email")) + ); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 89737cd..822956c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -51,6 +51,11 @@ model User { resetPasswordToken String? resetPasswordExpires DateTime? + // Compte employé créé par un admin avec un mot de passe provisoire envoyé + // par email : tant qu'il vaut true, seul le choix d'un nouveau mot de passe + // est autorisé (voir auth.middleware.js). + mustChangePassword Boolean @default(false) + provider AuthProvider @default(LOCAL) providerId String? avatarUrl String? diff --git a/backend/prisma/seed.js b/backend/prisma/seed.js index 370608d..eaffa64 100644 --- a/backend/prisma/seed.js +++ b/backend/prisma/seed.js @@ -1,4 +1,5 @@ const prisma = require("../src/config/prisma"); +const { DEFAULT_START_DATE } = require("../src/utils/gameWindow"); async function main() { await prisma.lot.deleteMany(); @@ -42,8 +43,8 @@ async function main() { const existingSettings = await prisma.gameSettings.findFirst(); if (!existingSettings) { - await prisma.gameSettings.create({ data: {} }); - console.log("Date de début du jeu-concours initialisée à aujourd'hui."); + await prisma.gameSettings.create({ data: { startDate: DEFAULT_START_DATE } }); + console.log("Date de début du jeu-concours initialisée au 2 octobre 2026."); } else { console.log("Date de début du jeu-concours déjà définie, inchangée."); } diff --git a/backend/src/config/passport.js b/backend/src/config/passport.js index b9e2903..e69fe73 100644 --- a/backend/src/config/passport.js +++ b/backend/src/config/passport.js @@ -5,6 +5,7 @@ const crypto = require("crypto"); const prisma = require("./prisma"); const transporter = require("./mailer"); const getFrontendUrl = require("../utils/frontendUrl"); +const { normalizeEmail } = require("../validations/email.validation"); const PROVIDER_LABELS = { GOOGLE: "Google", FACEBOOK: "Facebook" }; @@ -58,7 +59,7 @@ function verifyOAuthProfile(provider) { return async (accessToken, refreshToken, profile, done) => { try { - const email = profile.emails?.[0]?.value; + const email = normalizeEmail(profile.emails?.[0]?.value); // Cas fréquent chez Facebook : compte ouvert avec un numéro de téléphone. if (!email) { diff --git a/backend/src/controllers/admin.controller.js b/backend/src/controllers/admin.controller.js index ecca247..93410e1 100644 --- a/backend/src/controllers/admin.controller.js +++ b/backend/src/controllers/admin.controller.js @@ -2,7 +2,8 @@ const bcrypt = require("bcrypt"); const crypto = require("crypto"); const prisma = require("../config/prisma"); const transporter = require("../config/mailer"); -const { getGameWindow } = require("../utils/gameWindow"); +const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow"); +const getFrontendUrl = require("../utils/frontendUrl"); const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"]; @@ -166,9 +167,30 @@ const getEmailingContacts = async (req, res) => { } }; +// Sans caractères ambigus (0/O, 1/l/I) : l'employé peut devoir le recopier. +const TEMP_PASSWORD_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789"; +// Aucun caractère à échapper dans l'email HTML (&, <, >, guillemets). +const TEMP_PASSWORD_SPECIALS = "!@#$%*?"; +const TEMP_PASSWORD_LENGTH = 12; + +// Respecte la règle commune des mots de passe (au moins un caractère spécial), +// placé à une position aléatoire. +function generateTempPassword() { + const chars = []; + for (let i = 0; i < TEMP_PASSWORD_LENGTH - 1; i++) { + chars.push(TEMP_PASSWORD_ALPHABET[crypto.randomInt(TEMP_PASSWORD_ALPHABET.length)]); + } + const special = TEMP_PASSWORD_SPECIALS[crypto.randomInt(TEMP_PASSWORD_SPECIALS.length)]; + chars.splice(crypto.randomInt(chars.length + 1), 0, special); + return chars.join(""); +} + +// L'admin ne choisit pas le mot de passe de l'employé : on en génère un +// provisoire, envoyé uniquement par email, que l'employé devra remplacer à +// sa première connexion (mustChangePassword). const createEmployee = async (req, res) => { try { - const { firstname, lastname, email, password, birthdate, gender } = req.body; + const { firstname, lastname, email, birthdate, gender } = req.body; const existingUser = await prisma.user.findUnique({ where: { email }, @@ -180,7 +202,8 @@ const createEmployee = async (req, res) => { }); } - const hashedPassword = await bcrypt.hash(password, 10); + const tempPassword = generateTempPassword(); + const hashedPassword = await bcrypt.hash(tempPassword, 10); const employee = await prisma.user.create({ data: { @@ -192,6 +215,8 @@ const createEmployee = async (req, res) => { gender, role: "EMPLOYEE", marketingOptIn: false, + isEmailVerified: true, + mustChangePassword: true, }, select: { id: true, @@ -203,8 +228,45 @@ const createEmployee = async (req, res) => { }, }); + const loginUrl = `${getFrontendUrl()}/connexion`; + + try { + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: email, + subject: "Votre compte employé Thé Tip Top", + html: ` + <div style="font-family:sans-serif;max-width:480px;margin:auto"> + <h2 style="color:#006B6B">Bienvenue ${firstname} !</h2> + <p>Un compte employé Thé Tip Top vient d'être créé pour vous.</p> + <p>Voici vos identifiants de première connexion :</p> + <p style="background:#f3f4f6;padding:12px 16px;border-radius:8px"> + Email : <strong>${email}</strong><br /> + Mot de passe provisoire : <strong style="font-family:monospace;font-size:16px">${tempPassword}</strong> + </p> + <p style="text-align:center;padding:20px 0"> + <a href="${loginUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block"> + Me connecter + </a> + </p> + <p style="color:#888;font-size:13px">Dès votre première connexion, vous devrez + choisir un nouveau mot de passe. Le mot de passe provisoire ne fonctionnera + plus ensuite.</p> + </div> + `, + }); + } catch (mailError) { + // Sans l'email, l'employé n'a aucun moyen de connaître son mot de passe : + // on annule la création pour que l'admin puisse simplement réessayer. + console.error("Erreur envoi email employé:", mailError); + await prisma.user.delete({ where: { id: employee.id } }); + return res.status(502).json({ + message: "L'email d'invitation n'a pas pu être envoyé : le compte n'a pas été créé. Réessayez.", + }); + } + return res.status(201).json({ - message: "Employé créé avec succès.", + message: "Employé créé. Un email avec un mot de passe provisoire lui a été envoyé.", employee, }); } catch (error) { @@ -427,11 +489,137 @@ const getGrandPrizeWinner = async (req, res) => { } }; +const PERSON_SELECT = { id: true, firstname: true, lastname: true, email: true }; + +// Comptes employés, avec le nombre de lots que chacun a remis. `q` filtre sur +// le prénom, le nom ou l'email. +const getEmployees = async (req, res) => { + try { + const q = typeof req.query.q === "string" ? req.query.q.trim() : ""; + const contains = { contains: q, mode: "insensitive" }; + + const employees = await prisma.user.findMany({ + where: { + role: "EMPLOYEE", + ...(q && { OR: [{ firstname: contains }, { lastname: contains }, { email: contains }] }), + }, + select: { + ...PERSON_SELECT, + createdAt: true, + mustChangePassword: true, + _count: { select: { claimsValidated: { where: { status: "DELIVERED" } } } }, + }, + orderBy: [{ lastname: "asc" }, { firstname: "asc" }], + }); + + return res.json({ + employees: employees.map(({ _count, ...employee }) => ({ + ...employee, + deliveredCount: _count.claimsValidated, + })), + }); + } catch (error) { + console.error("Erreur liste employés:", error); + return res.status(500).json({ message: "Erreur serveur lors de la récupération des employés." }); + } +}; + +// Lots remis par un employé, du plus récent au plus ancien. +const getEmployeeDeliveries = async (req, res) => { + try { + const id = Number(req.params.id); + if (!Number.isInteger(id)) { + return res.status(400).json({ message: "Identifiant d'employé invalide." }); + } + + const employee = await prisma.user.findFirst({ + where: { id, role: "EMPLOYEE" }, + select: PERSON_SELECT, + }); + + if (!employee) { + return res.status(404).json({ message: "Employé introuvable." }); + } + + const claims = await prisma.claim.findMany({ + where: { validatedByEmployeeId: id, status: "DELIVERED" }, + orderBy: { claimedAt: "desc" }, + include: { + participation: { + include: { + ticket: { select: { code: true } }, + lot: { select: { name: true, value: true } }, + user: { select: PERSON_SELECT }, + }, + }, + }, + }); + + return res.json({ + employee, + deliveries: claims.map((claim) => ({ + claimId: claim.id, + ticketCode: claim.participation.ticket.code, + lot: claim.participation.lot, + client: claim.participation.user, + deliveredAt: claim.claimedAt, + })), + }); + } catch (error) { + console.error("Erreur remises employé:", error); + return res.status(500).json({ message: "Erreur serveur lors de la récupération des remises." }); + } +}; + +// Traçabilité d'un ticket : qui l'a joué, quel lot, et quel employé l'a remis +// et quand. `status` vaut NOT_PLAYED tant qu'aucun client ne l'a saisi. +const getDeliveryByTicketCode = async (req, res) => { + try { + const code = req.params.code.trim().toUpperCase(); + const gameWindow = await getGameWindow(); + + const ticket = await prisma.ticket.findUnique({ + where: { code }, + include: { + lot: { select: { name: true, value: true } }, + participation: { + include: { + user: { select: PERSON_SELECT }, + claim: { include: { validatedByEmployee: { select: PERSON_SELECT } } }, + }, + }, + }, + }); + + if (!ticket) { + return res.status(404).json({ message: "Aucun ticket ne correspond à ce numéro." }); + } + + const claim = ticket.participation?.claim; + + return res.json({ + ticketCode: ticket.code, + lot: ticket.lot, + status: claim ? effectiveClaimStatus(claim.status, gameWindow) : "NOT_PLAYED", + playedAt: ticket.usedAt, + client: ticket.participation?.user ?? null, + deliveredBy: claim?.validatedByEmployee ?? null, + deliveredAt: claim?.status === "DELIVERED" ? claim.claimedAt : null, + }); + } catch (error) { + console.error("Erreur recherche remise:", error); + return res.status(500).json({ message: "Erreur serveur lors de la recherche du ticket." }); + } +}; + module.exports = { getStats, getUsers, getEmailingContacts, createEmployee, + getEmployees, + getEmployeeDeliveries, + getDeliveryByTicketCode, drawWinner, getGrandPrizeWinner, setGameSettings, diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index cfc12bb..171108a 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -4,6 +4,8 @@ const crypto = require("crypto"); const prisma = require("../config/prisma"); const transporter = require("../config/mailer"); const getFrontendUrl = require("../utils/frontendUrl"); +const { passwordRuleError } = require("../validations/password.validation"); +const { normalizeEmail } = require("../validations/email.validation"); const register = async (req, res) => { try { @@ -211,6 +213,7 @@ const login = async (req, res) => { lastname: user.lastname, email: user.email, role: user.role, + mustChangePassword: user.mustChangePassword, }, }); } catch (error) { @@ -221,9 +224,47 @@ const login = async (req, res) => { } }; +const RESET_LINK_EXPIRY_MS = 1000 * 60 * 30; // 30 min + +// Enregistre un jeton de réinitialisation (qui remplace le précédent) et envoie +// le lien par email. Partagé par « mot de passe oublié » et par le profil. +async function sendPasswordResetEmail(user) { + const resetPasswordToken = crypto.randomBytes(32).toString("hex"); + + await prisma.user.update({ + where: { id: user.id }, + data: { + resetPasswordToken, + resetPasswordExpires: new Date(Date.now() + RESET_LINK_EXPIRY_MS), + }, + }); + + // 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 resetUrl = `${getFrontendUrl()}/definir-mot-de-passe/${resetPasswordToken}`; + + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: user.email, + subject: "Réinitialisation de votre mot de passe Thé Tip Top", + html: ` + <h2>Réinitialisation du mot de passe</h2> + <p>Bonjour ${user.firstname},</p> + <p>Vous avez demandé à définir un nouveau mot de passe.</p> + <p>Cliquez sur le lien ci-dessous pour le choisir :</p> + <a href="${resetUrl}">Choisir mon nouveau mot de passe</a> + <p>Ce lien expire dans 30 minutes.</p> + <p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p> + `, + }); +} + +const FORGOT_PASSWORD_MESSAGE = + "Si un compte existe avec cet email, un lien de réinitialisation vient de lui être envoyé."; + const forgotPassword = async (req, res) => { try { - const { email } = req.body; + const email = normalizeEmail(req.body.email); if (!email) { return res.status(400).json({ @@ -237,45 +278,12 @@ const forgotPassword = async (req, res) => { // Sécurité : ne pas dire si l'email existe ou non if (!user) { - return res.status(200).json({ - message: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.", - }); + return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE }); } - const resetPasswordToken = crypto.randomBytes(32).toString("hex"); - const resetPasswordExpires = new Date(Date.now() + 1000 * 60 * 30); // 30 min + await sendPasswordResetEmail(user); - await prisma.user.update({ - where: { id: user.id }, - data: { - resetPasswordToken, - resetPasswordExpires, - }, - }); - - // 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 = getFrontendUrl(); - const resetUrl = `${frontendUrl}/definir-mot-de-passe/${resetPasswordToken}`; - - await transporter.sendMail({ - from: process.env.MAIL_FROM, - to: user.email, - subject: "Réinitialisation de votre mot de passe Thé Tip Top", - html: ` - <h2>Réinitialisation du mot de passe</h2> - <p>Bonjour ${user.firstname},</p> - <p>Vous avez demandé la réinitialisation de votre mot de passe.</p> - <p>Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe :</p> - <a href="${resetUrl}">Réinitialiser mon mot de passe</a> - <p>Ce lien expire dans 30 minutes.</p> - <p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p> - `, - }); - - return res.status(200).json({ - message: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.", - }); + return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE }); } catch (error) { console.error("Erreur forgotPassword:", error); return res.status(500).json({ @@ -284,15 +292,27 @@ const forgotPassword = async (req, res) => { } }; +// Profil connecté (compte Google sans mot de passe) : même lien, envoyé à +// l'adresse du compte. Authentifié, donc sans captcha. +const sendPasswordSetupLink = async (req, res) => { + try { + const user = await prisma.user.findUnique({ where: { id: req.user.id } }); + await sendPasswordResetEmail(user); + return res.json({ message: `Lien envoyé à ${user.email}.` }); + } catch (error) { + console.error("Erreur sendPasswordSetupLink:", error); + return res.status(500).json({ message: "Erreur serveur lors de l'envoi du lien." }); + } +}; + const resetPassword = async (req, res) => { try { const { token } = req.params; const { password, confirmPassword } = req.body; - if (!password || password.length < 8) { - return res.status(400).json({ - message: "Le nouveau mot de passe doit contenir au moins 8 caractères.", - }); + const passwordError = passwordRuleError(password); + if (passwordError) { + return res.status(400).json({ message: passwordError }); } // La confirmation est vérifiée côté serveur aussi : le contrôle du @@ -326,6 +346,14 @@ const resetPassword = async (req, res) => { password: hashedPassword, resetPasswordToken: null, resetPasswordExpires: null, + // Le lien reçu par email prouve l'accès à la boîte : le mot de passe + // choisi ici remplace aussi un éventuel mot de passe provisoire, et + // l'adresse est considérée comme validée (sinon la connexion qui suit + // serait refusée pour email non vérifié). + mustChangePassword: false, + isEmailVerified: true, + emailVerifyToken: null, + emailVerifyExpires: null, }, }); @@ -342,7 +370,8 @@ const resetPassword = async (req, res) => { const verifyCode = async (req, res) => { try { - const { email, code } = req.body; + const { code } = req.body; + const email = normalizeEmail(req.body.email); if (!email || !code) { return res.status(400).json({ message: "Email et code requis." }); @@ -424,6 +453,7 @@ const oauthCallback = (req, res) => { lastname: user.lastname, email: user.email, role: user.role, + mustChangePassword: user.mustChangePassword, }) ).toString("base64"); @@ -491,6 +521,47 @@ const deleteAccount = async (req, res) => { } }; +// Première connexion d'un employé : remplace le mot de passe provisoire reçu +// par email. L'utilisateur vient de s'authentifier avec ce mot de passe, on ne +// le redemande donc pas. +const setInitialPassword = async (req, res) => { + try { + const { password, confirmPassword } = req.body; + + if (!req.user.mustChangePassword) { + return res.status(400).json({ message: "Votre mot de passe a déjà été défini." }); + } + + const passwordError = passwordRuleError(password); + if (passwordError) { + return res.status(400).json({ message: passwordError }); + } + + if (password !== confirmPassword) { + return res.status(400).json({ message: "Les deux mots de passe ne correspondent pas." }); + } + + const user = await prisma.user.findUnique({ where: { id: req.user.id } }); + + if (user.password && (await bcrypt.compare(password, user.password))) { + return res.status(400).json({ + message: "Choisissez un mot de passe différent du mot de passe provisoire.", + }); + } + + const hashed = await bcrypt.hash(password, 10); + await prisma.user.update({ + where: { id: user.id }, + data: { password: hashed, mustChangePassword: false }, + }); + + return res.json({ message: "Mot de passe enregistré." }); + } catch (error) { + console.error("Erreur setInitialPassword:", error); + return res.status(500).json({ message: "Erreur serveur lors de l'enregistrement du mot de passe." }); + } +}; + const changePassword = async (req, res) => { try { const { currentPassword, newPassword } = req.body; @@ -500,8 +571,9 @@ const changePassword = async (req, res) => { return res.status(400).json({ message: "Les deux mots de passe sont requis." }); } - if (newPassword.length < 8) { - return res.status(400).json({ message: "Le nouveau mot de passe doit faire au moins 8 caractères." }); + const passwordError = passwordRuleError(newPassword); + if (passwordError) { + return res.status(400).json({ message: passwordError }); } const user = await prisma.user.findUnique({ where: { id: userId } }); @@ -536,7 +608,9 @@ module.exports = { verifyCode, resetPassword, forgotPassword, + sendPasswordSetupLink, changePassword, + setInitialPassword, deleteAccount, oauthCallback, }; \ No newline at end of file diff --git a/backend/src/controllers/game.controller.js b/backend/src/controllers/game.controller.js index 9725af2..b7593d6 100644 --- a/backend/src/controllers/game.controller.js +++ b/backend/src/controllers/game.controller.js @@ -185,7 +185,20 @@ const myGains = async (req, res) => { } }; +// Dates publiques du jeu-concours, affichées sur la page d'accueil : elles +// suivent la date de début fixée par l'admin. +const getPublicGameWindow = async (req, res) => { + try { + const { configured, phase, startDate, participationDeadline, claimDeadline } = await getGameWindow(); + return res.json({ configured, phase, startDate, participationDeadline, claimDeadline }); + } catch (error) { + console.error("Erreur lecture dates du jeu:", error); + return res.status(500).json({ message: "Erreur serveur lors de la lecture des dates du jeu-concours." }); + } +}; + module.exports = { participate, myGains, + getPublicGameWindow, }; \ No newline at end of file diff --git a/backend/src/middlewares/auth.middleware.js b/backend/src/middlewares/auth.middleware.js index 072501e..c661a5e 100644 --- a/backend/src/middlewares/auth.middleware.js +++ b/backend/src/middlewares/auth.middleware.js @@ -1,6 +1,11 @@ const jwt = require("jsonwebtoken"); const prisma = require("../config/prisma"); +// Seules routes accessibles tant qu'un mot de passe provisoire n'a pas été +// remplacé : sans ce filtre côté serveur, il suffirait d'appeler l'API +// directement pour contourner la page du front. +const ALLOWED_WHILE_PASSWORD_CHANGE_REQUIRED = ["/api/auth/me", "/api/auth/initial-password"]; + const authMiddleware = async (req, res, next) => { try { const authHeader = req.headers.authorization; @@ -25,6 +30,7 @@ const authMiddleware = async (req, res, next) => { role: true, provider: true, password: true, + mustChangePassword: true, }, }); @@ -34,6 +40,14 @@ const authMiddleware = async (req, res, next) => { }); } + const route = `${req.baseUrl}${req.path}`.replace(/\/+$/, ""); + if (user.mustChangePassword && !ALLOWED_WHILE_PASSWORD_CHANGE_REQUIRED.includes(route)) { + return res.status(403).json({ + message: "Vous devez choisir un nouveau mot de passe avant de continuer.", + code: "PASSWORD_CHANGE_REQUIRED", + }); + } + // 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. diff --git a/backend/src/routes/admin.routes.js b/backend/src/routes/admin.routes.js index acd0e54..43427db 100644 --- a/backend/src/routes/admin.routes.js +++ b/backend/src/routes/admin.routes.js @@ -4,6 +4,9 @@ const { getUsers, getEmailingContacts, createEmployee, + getEmployees, + getEmployeeDeliveries, + getDeliveryByTicketCode, drawWinner, getGrandPrizeWinner, setGameSettings, @@ -81,6 +84,73 @@ router.get("/emailing", getEmailingContacts); */ router.post("/employees", validate(createEmployeeSchema), createEmployee); +/** + * @swagger + * /api/admin/employees: + * get: + * summary: Liste des comptes employés avec leur nombre de lots remis + * tags: + * - Admin + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: q + * schema: + * type: string + * description: Filtre sur le prénom, le nom ou l'email + * responses: + * 200: + * description: Employés + */ +router.get("/employees", getEmployees); + +/** + * @swagger + * /api/admin/employees/{id}/deliveries: + * get: + * summary: Lots remis par un employé + * tags: + * - Admin + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: integer + * responses: + * 200: + * description: Employé et liste de ses remises (ticket, lot, client, date) + * 404: + * description: Employé introuvable + */ +router.get("/employees/:id/deliveries", getEmployeeDeliveries); + +/** + * @swagger + * /api/admin/deliveries/{code}: + * get: + * summary: Traçabilité d'un ticket (lot, client, employé ayant remis le lot, date) + * tags: + * - Admin + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: code + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Statut du ticket (NOT_PLAYED, PENDING, DELIVERED, CANCELLED) et remise éventuelle + * 404: + * description: Aucun ticket ne correspond à ce numéro + */ +router.get("/deliveries/:code", getDeliveryByTicketCode); + /** * @swagger * /api/admin/draw: diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index 33cf984..dcb3ed8 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -1,5 +1,5 @@ const express = require("express"); -const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller"); +const { register, login, verifyEmail, verifyCode, forgotPassword, sendPasswordSetupLink, resetPassword, me, changePassword, setInitialPassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller"); const authMiddleware = require("../middlewares/auth.middleware"); const validate = require("../middlewares/validate.middleware"); const verifyCaptcha = require("../middlewares/captcha.middleware"); @@ -113,16 +113,36 @@ router.get("/verify-email/:token",verifyEmail); * type: string * format: email * example: jean.dupont@gmail.com + * captchaToken: + * type: string + * description: Jeton Cloudflare Turnstile (exigé si TURNSTILE_SECRET_KEY est configurée) * responses: * 200: * description: Si un compte existe avec cet email, un lien de réinitialisation a été envoyé. * 400: - * description: Email requis. + * description: Email requis, ou vérification anti-robot manquante. * 500: * description: Erreur serveur. */ -router.post("/forgot-password", forgotPassword); +// Page publique : le captcha empêche d'envoyer des emails en masse vers +// n'importe quelle adresse. +router.post("/forgot-password", verifyCaptcha, forgotPassword); + +/** + * @swagger + * /api/auth/password-setup-link: + * post: + * summary: Envoie à l'utilisateur connecté un lien pour définir son mot de passe + * tags: + * - Auth + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Lien envoyé à l'adresse du compte. + */ +router.post("/password-setup-link", authMiddleware, sendPasswordSetupLink); /** * @swagger * /api/auth/reset-password/{token}: @@ -162,6 +182,37 @@ router.post("/reset-password/:token", resetPassword); router.post("/verify-code", verifyCode); router.patch("/change-password", authMiddleware, changePassword); + +/** + * @swagger + * /api/auth/initial-password: + * post: + * summary: Remplace le mot de passe provisoire d'un employé à sa première connexion + * tags: + * - Auth + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - password + * - confirmPassword + * properties: + * password: + * type: string + * confirmPassword: + * type: string + * responses: + * 200: + * description: Mot de passe enregistré, l'accès complet est rétabli. + * 400: + * description: Mot de passe invalide, non confirmé, identique au provisoire ou déjà défini. + */ +router.post("/initial-password", authMiddleware, setInitialPassword); router.delete("/account", authMiddleware, deleteAccount); /** diff --git a/backend/src/routes/game.routes.js b/backend/src/routes/game.routes.js index 69c4eaa..64a338e 100644 --- a/backend/src/routes/game.routes.js +++ b/backend/src/routes/game.routes.js @@ -1,5 +1,5 @@ const express = require("express"); -const { participate, myGains } = require("../controllers/game.controller"); +const { participate, myGains, getPublicGameWindow } = require("../controllers/game.controller"); const authMiddleware = require("../middlewares/auth.middleware"); const roleMiddleware = require("../middlewares/role.middleware"); const validate = require("../middlewares/validate.middleware"); @@ -41,4 +41,17 @@ router.post("/participate", authMiddleware, roleMiddleware("CLIENT"), validate(p router.get("/my-gains", authMiddleware, roleMiddleware("CLIENT"), myGains); +/** + * @swagger + * /api/game/window: + * get: + * summary: Dates publiques du jeu-concours (début, fin des participations, fin des retraits) + * tags: + * - Game + * responses: + * 200: + * description: Phase et échéances courantes ; dates nulles si le jeu n'est pas configuré + */ +router.get("/window", getPublicGameWindow); + module.exports = router; \ No newline at end of file diff --git a/backend/src/utils/gameWindow.js b/backend/src/utils/gameWindow.js index 65c19c1..47a9fbd 100644 --- a/backend/src/utils/gameWindow.js +++ b/backend/src/utils/gameWindow.js @@ -11,6 +11,11 @@ const PHASES = { CLOSED: "CLOSED", }; +// Date de lancement prévue par le règlement : du 2 au 31 octobre 2026 (heure +// de Paris). Utilisée pour initialiser la base ; l'admin peut la corriger +// depuis son tableau de bord. +const DEFAULT_START_DATE = new Date("2026-10-02T00:00:00+02:00"); + const GAME_DURATION_DAYS = 30; const CLAIM_EXTENSION_DAYS = 30; const DAY_MS = 24 * 60 * 60 * 1000; @@ -95,6 +100,7 @@ module.exports = { effectiveClaimStatus, participationClosedMessage, PHASES, + DEFAULT_START_DATE, GAME_DURATION_DAYS, CLAIM_EXTENSION_DAYS, }; diff --git a/backend/src/validations/admin.validation.js b/backend/src/validations/admin.validation.js index c6771bf..3d47820 100644 --- a/backend/src/validations/admin.validation.js +++ b/backend/src/validations/admin.validation.js @@ -1,10 +1,11 @@ const { z } = require("zod"); +const { emailSchema } = require("./email.validation"); const createEmployeeSchema = z.object({ firstname: z.string().min(2), lastname: z.string().min(2), - email: z.string().email(), - password: z.string().min(8), + // Pas de mot de passe : il est généré côté serveur (voir createEmployee). + email: emailSchema, birthdate: z.string().optional(), gender: z.string().optional(), }); diff --git a/backend/src/validations/auth.validation.js b/backend/src/validations/auth.validation.js index 4061a73..e8477e5 100644 --- a/backend/src/validations/auth.validation.js +++ b/backend/src/validations/auth.validation.js @@ -1,17 +1,27 @@ const { z } = require("zod"); +const { passwordSchema } = require("./password.validation"); +const { emailSchema } = require("./email.validation"); -const registerSchema = z.object({ - firstname: z.string().min(2, "Le prénom doit contenir au moins 2 caractères"), - lastname: z.string().min(2, "Le nom doit contenir au moins 2 caractères"), - email: z.string().email("Email invalide"), - password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"), - birthdate: z.string().optional(), - gender: z.string().optional(), - marketingOptIn: z.boolean().optional(), -}); +const registerSchema = z + .object({ + firstname: z.string().min(2, "Le prénom doit contenir au moins 2 caractères"), + lastname: z.string().min(2, "Le nom doit contenir au moins 2 caractères"), + email: emailSchema, + password: passwordSchema, + // Vérifiée aussi côté serveur : le contrôle du navigateur ne protège pas + // un appel direct à l'API. + confirmPassword: z.string({ error: "Veuillez confirmer le mot de passe" }), + birthdate: z.string().optional(), + gender: z.string().optional(), + marketingOptIn: z.boolean().optional(), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Les deux mots de passe ne correspondent pas", + path: ["confirmPassword"], + }); const loginSchema = z.object({ - email: z.string().email("Email invalide"), + email: emailSchema, password: z.string().min(1, "Mot de passe requis"), }); diff --git a/backend/src/validations/email.validation.js b/backend/src/validations/email.validation.js new file mode 100644 index 0000000..715789c --- /dev/null +++ b/backend/src/validations/email.validation.js @@ -0,0 +1,11 @@ +const { z } = require("zod"); + +// Les emails sont stockés et recherchés en minuscules, sans espaces autour : +// « Jean@Exemple.fr » et « jean@exemple.fr » désignent le même compte. +function normalizeEmail(email) { + return typeof email === "string" ? email.trim().toLowerCase() : email; +} + +const emailSchema = z.string().trim().toLowerCase().email("Email invalide"); + +module.exports = { emailSchema, normalizeEmail }; diff --git a/backend/src/validations/password.validation.js b/backend/src/validations/password.validation.js new file mode 100644 index 0000000..a04e1bf --- /dev/null +++ b/backend/src/validations/password.validation.js @@ -0,0 +1,21 @@ +const { z } = require("zod"); + +// Règle de mot de passe, reprise à l'identique par le front +// (frontend/src/utils/passwordRules.js) : plus de 7 caractères et au moins un +// caractère spécial, c'est-à-dire ni lettre (accentuée comprise), ni chiffre, +// ni espace. +const SPECIAL_CHARACTER = /[^\p{L}\p{N}\s]/u; + +const passwordSchema = z + .string() + .min(8, "Le mot de passe doit contenir au moins 8 caractères") + .regex(SPECIAL_CHARACTER, "Le mot de passe doit contenir au moins un caractère spécial"); + +// Pour les contrôleurs qui valident à la main : message de la première règle +// non respectée, ou null si le mot de passe est valide. +function passwordRuleError(password) { + const result = passwordSchema.safeParse(password ?? ""); + return result.success ? null : `${result.error.issues[0].message}.`; +} + +module.exports = { passwordSchema, passwordRuleError, SPECIAL_CHARACTER }; diff --git a/backend/tests/adminDeliveries.test.js b/backend/tests/adminDeliveries.test.js new file mode 100644 index 0000000..16198b8 --- /dev/null +++ b/backend/tests/adminDeliveries.test.js @@ -0,0 +1,144 @@ +const request = require("supertest"); +const bcrypt = require("bcrypt"); +const jwt = require("jsonwebtoken"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) })); + +const PREFIX = "tracabilite-"; +const CODES = ["TRACE00001", "TRACE00002", "TRACE00003", "TRACE00004"]; +const ctx = {}; + +const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" }); +const asAdmin = (req) => req.set("Authorization", `Bearer ${ctx.tAdmin}`); + +async function cleanup() { + const users = await prisma.user.findMany({ where: { email: { startsWith: PREFIX } } }); + const ids = users.map((u) => u.id); + const participations = await prisma.participation.findMany({ where: { userId: { in: ids } } }); + await prisma.claim.deleteMany({ where: { participationId: { in: participations.map((p) => p.id) } } }); + await prisma.participation.deleteMany({ where: { userId: { in: ids } } }); + await prisma.ticket.deleteMany({ where: { code: { in: CODES } } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); +} + +// Ticket joué par le client, avec une réclamation éventuellement remise. +async function jouer(code, { remisPar, remisLe } = {}) { + const ticket = await prisma.ticket.create({ + data: { code, lotId: ctx.lot.id, isUsed: true, usedAt: new Date("2026-09-01T10:00:00Z"), userId: ctx.client.id }, + }); + const participation = await prisma.participation.create({ + data: { userId: ctx.client.id, ticketId: ticket.id, lotId: ctx.lot.id }, + }); + return prisma.claim.create({ + data: { + participationId: participation.id, + status: remisPar ? "DELIVERED" : "PENDING", + validatedByEmployeeId: remisPar?.id ?? null, + claimedAt: remisLe ?? null, + }, + }); +} + +beforeAll(async () => { + await cleanup(); + const h = await bcrypt.hash("Password123", 10); + const mk = (email, role, firstname, lastname, extra = {}) => + prisma.user.create({ data: { firstname, lastname, email: PREFIX + email, password: h, role, isEmailVerified: true, ...extra } }); + + ctx.admin = await mk("admin@test.local", "ADMIN", "Alice", "Admin"); + ctx.marc = await mk("marc@test.local", "EMPLOYEE", "Marc", "Durand"); + ctx.sophie = await mk("sophie@test.local", "EMPLOYEE", "Sophie", "Bernard", { mustChangePassword: true }); + ctx.client = await mk("client@test.local", "CLIENT", "Chloé", "Martin"); + ctx.tAdmin = tk(ctx.admin); + ctx.lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } }); + + await jouer(CODES[0], { remisPar: ctx.marc, remisLe: new Date("2026-09-10T14:30:00Z") }); + await jouer(CODES[1], { remisPar: ctx.marc, remisLe: new Date("2026-09-12T09:15:00Z") }); + await jouer(CODES[2]); + await prisma.ticket.create({ data: { code: CODES[3], lotId: ctx.lot.id } }); +}); + +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Liste des employes", () => { + test("renvoie les employes avec leur nombre de lots remis", async () => { + const res = await asAdmin(request(app).get("/api/admin/employees")).expect(200); + + const marc = res.body.employees.find((e) => e.id === ctx.marc.id); + const sophie = res.body.employees.find((e) => e.id === ctx.sophie.id); + expect(marc.deliveredCount).toBe(2); + expect(sophie.deliveredCount).toBe(0); + expect(sophie.mustChangePassword).toBe(true); + // Ni client, ni admin, ni hash de mot de passe. + expect(res.body.employees.every((e) => e.id !== ctx.client.id && e.id !== ctx.admin.id)).toBe(true); + expect(JSON.stringify(res.body)).not.toMatch(/"password"/); + }); + + test("filtre par nom ou email avec q", async () => { + const res = await asAdmin(request(app).get("/api/admin/employees?q=BERNARD")).expect(200); + expect(res.body.employees.map((e) => e.id)).toEqual([ctx.sophie.id]); + }); + + test("est reserve a l admin", async () => { + await request(app).get("/api/admin/employees").set("Authorization", `Bearer ${tk(ctx.marc)}`).expect(403); + }); +}); + +describe("Lots remis par un employe", () => { + test("liste les remises de l employe, de la plus recente a la plus ancienne", async () => { + const res = await asAdmin(request(app).get(`/api/admin/employees/${ctx.marc.id}/deliveries`)).expect(200); + + expect(res.body.employee.email).toBe(PREFIX + "marc@test.local"); + expect(res.body.deliveries.map((d) => d.ticketCode)).toEqual([CODES[1], CODES[0]]); + expect(res.body.deliveries[0].client.firstname).toBe("Chloé"); + expect(res.body.deliveries[0].lot.name).toBe(ctx.lot.name); + expect(new Date(res.body.deliveries[0].deliveredAt).toISOString()).toBe("2026-09-12T09:15:00.000Z"); + }); + + test("renvoie une liste vide pour un employe sans remise", async () => { + const res = await asAdmin(request(app).get(`/api/admin/employees/${ctx.sophie.id}/deliveries`)).expect(200); + expect(res.body.deliveries).toEqual([]); + }); + + test("refuse un identifiant qui n est pas un employe", async () => { + await asAdmin(request(app).get(`/api/admin/employees/${ctx.client.id}/deliveries`)).expect(404); + await asAdmin(request(app).get("/api/admin/employees/abc/deliveries")).expect(400); + }); +}); + +describe("Recherche par numero de ticket", () => { + test("indique l employe qui a remis le lot et la date, sans tenir compte de la casse", async () => { + const res = await asAdmin(request(app).get(`/api/admin/deliveries/${CODES[0].toLowerCase()}`)).expect(200); + + expect(res.body.ticketCode).toBe(CODES[0]); + expect(res.body.status).toBe("DELIVERED"); + expect(res.body.deliveredBy).toMatchObject({ id: ctx.marc.id, firstname: "Marc", lastname: "Durand" }); + expect(new Date(res.body.deliveredAt).toISOString()).toBe("2026-09-10T14:30:00.000Z"); + expect(res.body.client.email).toBe(PREFIX + "client@test.local"); + }); + + test("un lot gagne mais pas encore remis n a pas d employe", async () => { + const res = await asAdmin(request(app).get(`/api/admin/deliveries/${CODES[2]}`)).expect(200); + + // PENDING, ou CANCELLED si la période de retrait du jeu en base est passée. + expect(["PENDING", "CANCELLED"]).toContain(res.body.status); + expect(res.body.deliveredBy).toBeNull(); + expect(res.body.deliveredAt).toBeNull(); + }); + + test("un ticket jamais joue est signale comme tel", async () => { + const res = await asAdmin(request(app).get(`/api/admin/deliveries/${CODES[3]}`)).expect(200); + + expect(res.body.status).toBe("NOT_PLAYED"); + expect(res.body.client).toBeNull(); + }); + + test("un numero inconnu renvoie 404", async () => { + await asAdmin(request(app).get("/api/admin/deliveries/INEXISTANT")).expect(404); + }); +}); diff --git a/backend/tests/e2e.flow.test.js b/backend/tests/e2e.flow.test.js index ddb2ae2..fe92d46 100644 --- a/backend/tests/e2e.flow.test.js +++ b/backend/tests/e2e.flow.test.js @@ -21,13 +21,31 @@ const testEmail = "e2e-flow-test@test.com"; const ticketCode = "ZZE2ETEST1"; let token; let createdTicketId; +let settingsBackup; beforeAll(async () => { await prisma.user.deleteMany({ where: { email: testEmail } }); await prisma.ticket.deleteMany({ where: { code: ticketCode } }); + + // Le parcours a besoin d'un jeu ouvert, quelle que soit la date de début + // enregistrée en base (par défaut le 2 octobre 2026) : on la place à hier + // le temps du test, puis on la restaure. + settingsBackup = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + const hier = new Date(Date.now() - 24 * 60 * 60 * 1000); + if (settingsBackup) { + await prisma.gameSettings.update({ where: { id: settingsBackup.id }, data: { startDate: hier } }); + } else { + await prisma.gameSettings.create({ data: { startDate: hier } }); + } }); afterAll(async () => { + if (settingsBackup) { + await prisma.gameSettings.update({ where: { id: settingsBackup.id }, data: { startDate: settingsBackup.startDate } }); + } else { + await prisma.gameSettings.deleteMany({}); + } + const user = await prisma.user.findUnique({ where: { email: testEmail } }); if (user) { await prisma.claim.deleteMany({ where: { participation: { userId: user.id } } }); @@ -48,7 +66,8 @@ describe("THE TIP TOP - FULL E2E FLOW", () => { firstname: "E2E", lastname: "Test", email: testEmail, - password: "Password123", + password: "Password123!", + confirmPassword: "Password123!", }); expect(res.statusCode).toBe(201); @@ -62,7 +81,7 @@ describe("THE TIP TOP - FULL E2E FLOW", () => { const res = await request(app).post("/api/auth/login").send({ email: testEmail, - password: "Password123", + password: "Password123!", }); expect(res.statusCode).toBe(200); diff --git a/backend/tests/employeeOnboarding.test.js b/backend/tests/employeeOnboarding.test.js new file mode 100644 index 0000000..1f052b7 --- /dev/null +++ b/backend/tests/employeeOnboarding.test.js @@ -0,0 +1,166 @@ +const request = require("supertest"); +const bcrypt = require("bcrypt"); +const jwt = require("jsonwebtoken"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const mailer = require("../src/config/mailer"); +const { passwordRuleError } = require("../src/validations/password.validation"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() })); + +const PREFIX = "onboarding-"; +const ADMIN_EMAIL = PREFIX + "admin@test.local"; +const EMPLOYEE_EMAIL = PREFIX + "employe@test.local"; +const ctx = {}; + +const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" }); + +// Récupère le mot de passe provisoire dans l'email envoyé à l'employé. +function tempPasswordFromMail() { + const html = mailer.sendMail.mock.calls[0][0].html; + return html.match(/Mot de passe provisoire : <strong[^>]*>([^<]+)<\/strong>/)[1]; +} + +async function cleanup() { + await prisma.user.deleteMany({ where: { email: { startsWith: PREFIX } } }); +} + +beforeAll(async () => { + await cleanup(); + const h = await bcrypt.hash("Password123", 10); + ctx.admin = await prisma.user.create({ + data: { firstname: "Alice", lastname: "Admin", email: ADMIN_EMAIL, password: h, role: "ADMIN", isEmailVerified: true }, + }); + ctx.tAdmin = tk(ctx.admin); +}); + +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Creation d un employe avec mot de passe provisoire", () => { + test("un echec d envoi de l email annule la creation du compte", async () => { + mailer.sendMail.mockRejectedValueOnce(new Error("SMTP indisponible")); + jest.spyOn(console, "error").mockImplementation(() => {}); + + const res = await request(app) + .post("/api/admin/employees") + .set("Authorization", `Bearer ${ctx.tAdmin}`) + .send({ firstname: "Marc", lastname: "Durand", email: EMPLOYEE_EMAIL }); + + console.error.mockRestore(); + expect(res.status).toBe(502); + expect(await prisma.user.findUnique({ where: { email: EMPLOYEE_EMAIL } })).toBeNull(); + }); + + test("l admin cree l employe sans mot de passe, un mot de passe provisoire est envoye", async () => { + mailer.sendMail.mockClear(); + mailer.sendMail.mockResolvedValue(true); + + const res = await request(app) + .post("/api/admin/employees") + .set("Authorization", `Bearer ${ctx.tAdmin}`) + // Un mot de passe choisi par l'admin est ignoré. + .send({ firstname: "Marc", lastname: "Durand", email: EMPLOYEE_EMAIL, password: "ChoixAdmin123" }); + + expect(res.status).toBe(201); + expect(res.body.employee.role).toBe("EMPLOYEE"); + // Le mot de passe ne transite jamais par la réponse faite à l'admin. + expect(JSON.stringify(res.body)).not.toMatch(/password/i); + + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + expect(mailer.sendMail.mock.calls[0][0].to).toBe(EMPLOYEE_EMAIL); + ctx.tempPassword = tempPasswordFromMail(); + expect(ctx.tempPassword).toHaveLength(12); + expect(ctx.tempPassword).not.toBe("ChoixAdmin123"); + // Le provisoire respecte lui aussi la règle commune. + expect(passwordRuleError(ctx.tempPassword)).toBeNull(); + + const enBase = await prisma.user.findUnique({ where: { email: EMPLOYEE_EMAIL } }); + expect(enBase.mustChangePassword).toBe(true); + expect(await bcrypt.compare("ChoixAdmin123", enBase.password)).toBe(false); + }); + + test("la connexion avec le mot de passe provisoire signale le changement obligatoire", async () => { + const res = await request(app).post("/api/auth/login").send({ email: EMPLOYEE_EMAIL, password: ctx.tempPassword }); + + expect(res.status).toBe(200); + expect(res.body.user.mustChangePassword).toBe(true); + ctx.tEmployee = res.body.token; + }); + + test("tant que le mot de passe n est pas change, l API est bloquee sauf /me", async () => { + const bloque = await request(app).get("/api/employee/ticket/INCONNU").set("Authorization", `Bearer ${ctx.tEmployee}`); + expect(bloque.status).toBe(403); + expect(bloque.body.code).toBe("PASSWORD_CHANGE_REQUIRED"); + + const changement = await request(app) + .patch("/api/auth/change-password") + .set("Authorization", `Bearer ${ctx.tEmployee}`) + .send({ currentPassword: ctx.tempPassword, newPassword: "Contournement1" }); + expect(changement.status).toBe(403); + + const me = await request(app).get("/api/auth/me").set("Authorization", `Bearer ${ctx.tEmployee}`); + expect(me.status).toBe(200); + expect(me.body.user.mustChangePassword).toBe(true); + }); + + test("le nouveau mot de passe doit etre confirme et different du provisoire", async () => { + const auth = { Authorization: `Bearer ${ctx.tEmployee}` }; + + const different = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: "NouveauMdp123!", confirmPassword: "AutreMdp456!" }); + expect(different.status).toBe(400); + expect(different.body.message).toMatch(/ne correspondent pas/); + + const court = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: "court", confirmPassword: "court" }); + expect(court.status).toBe(400); + + const sansSpecial = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: "NouveauMdp123", confirmPassword: "NouveauMdp123" }); + expect(sansSpecial.status).toBe(400); + expect(sansSpecial.body.message).toMatch(/caractère spécial/); + + const identique = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: ctx.tempPassword, confirmPassword: ctx.tempPassword }); + expect(identique.status).toBe(400); + expect(identique.body.message).toMatch(/provisoire/); + }); + + test("apres le changement, l acces est retabli et seul le nouveau mot de passe fonctionne", async () => { + const auth = { Authorization: `Bearer ${ctx.tEmployee}` }; + + const ok = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: "NouveauMdp123!", confirmPassword: "NouveauMdp123!" }); + expect(ok.status).toBe(200); + + // Le ticket n'existe pas : un 404 prouve que la requête a franchi le filtre. + const acces = await request(app).get("/api/employee/ticket/INCONNU").set(auth); + expect(acces.status).not.toBe(403); + + const encore = await request(app) + .post("/api/auth/initial-password") + .set(auth) + .send({ password: "EncoreUnAutre1!", confirmPassword: "EncoreUnAutre1!" }); + expect(encore.status).toBe(400); + + const ancien = await request(app).post("/api/auth/login").send({ email: EMPLOYEE_EMAIL, password: ctx.tempPassword }); + expect(ancien.status).toBe(401); + + const nouveau = await request(app).post("/api/auth/login").send({ email: EMPLOYEE_EMAIL, password: "NouveauMdp123!" }); + expect(nouveau.status).toBe(200); + expect(nouveau.body.user.mustChangePassword).toBe(false); + }); +}); diff --git a/backend/tests/forgotPassword.test.js b/backend/tests/forgotPassword.test.js new file mode 100644 index 0000000..0b95155 --- /dev/null +++ b/backend/tests/forgotPassword.test.js @@ -0,0 +1,167 @@ +const request = require("supertest"); +const bcrypt = require("bcrypt"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const mailer = require("../src/config/mailer"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) })); + +const EMAIL = "forgot-password-test@test.local"; +const NON_VERIFIE = "forgot-password-unverified@test.local"; +const INSCRIT = "forgot-password-register@test.local"; + +// Jeton extrait du lien reçu par email. +function tokenFromMail() { + const html = mailer.sendMail.mock.calls.at(-1)[0].html; + return html.match(/\/definir-mot-de-passe\/([a-f0-9]+)"/)[1]; +} + +async function cleanup() { + await prisma.user.deleteMany({ where: { email: { in: [EMAIL, NON_VERIFIE, INSCRIT] } } }); +} + +beforeAll(async () => { + await cleanup(); + const h = await bcrypt.hash("AncienMdp1!", 10); + await prisma.user.create({ data: { firstname: "Léa", lastname: "Durand", email: EMAIL, password: h, isEmailVerified: true } }); + await prisma.user.create({ data: { firstname: "Tom", lastname: "Petit", email: NON_VERIFIE, password: h, isEmailVerified: false } }); +}); + +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Mot de passe oublie", () => { + beforeEach(() => mailer.sendMail.mockClear()); + + test("un email inconnu recoit la meme reponse, sans envoi", async () => { + const res = await request(app).post("/api/auth/forgot-password").send({ email: "inconnu@test.local" }); + + expect(res.status).toBe(200); + expect(res.body.message).toMatch(/Si un compte existe/); + expect(mailer.sendMail).not.toHaveBeenCalled(); + }); + + test("un email connu recoit un lien vers la page du front", async () => { + const res = await request(app).post("/api/auth/forgot-password").send({ email: EMAIL }); + + expect(res.status).toBe(200); + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + const mail = mailer.sendMail.mock.calls[0][0]; + expect(mail.to).toBe(EMAIL); + const frontendUrl = (process.env.FRONTEND_URL || "http://localhost:5173").split(",")[0].trim(); + expect(mail.html).toContain(`${frontendUrl}/definir-mot-de-passe/`); + expect(tokenFromMail()).toHaveLength(64); + }); + + test("une nouvelle demande invalide le lien precedent", async () => { + await request(app).post("/api/auth/forgot-password").send({ email: EMAIL }); + const ancien = tokenFromMail(); + await request(app).post("/api/auth/forgot-password").send({ email: EMAIL }); + const nouveau = tokenFromMail(); + + const res = await request(app) + .post(`/api/auth/reset-password/${ancien}`) + .send({ password: "NouveauMdp1!", confirmPassword: "NouveauMdp1!" }); + expect(res.status).toBe(400); + expect(nouveau).not.toBe(ancien); + }); + + test("le lien permet de changer le mot de passe une seule fois", async () => { + await request(app).post("/api/auth/forgot-password").send({ email: EMAIL }); + const token = tokenFromMail(); + + const ok = await request(app) + .post(`/api/auth/reset-password/${token}`) + .send({ password: "NouveauMdp1!", confirmPassword: "NouveauMdp1!" }); + expect(ok.status).toBe(200); + + expect((await request(app).post("/api/auth/login").send({ email: EMAIL, password: "AncienMdp1!" })).status).toBe(401); + expect((await request(app).post("/api/auth/login").send({ email: EMAIL, password: "NouveauMdp1!" })).status).toBe(200); + + const reutilise = await request(app) + .post(`/api/auth/reset-password/${token}`) + .send({ password: "EncoreAutre1!", confirmPassword: "EncoreAutre1!" }); + expect(reutilise.status).toBe(400); + expect(reutilise.body.message).toMatch(/invalide ou expiré/); + }); + + test("un lien expire est refuse", async () => { + await request(app).post("/api/auth/forgot-password").send({ email: EMAIL }); + const token = tokenFromMail(); + await prisma.user.update({ where: { email: EMAIL }, data: { resetPasswordExpires: new Date(Date.now() - 1000) } }); + + const res = await request(app) + .post(`/api/auth/reset-password/${token}`) + .send({ password: "EncoreAutre1!", confirmPassword: "EncoreAutre1!" }); + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/invalide ou expiré/); + }); + + test("l email est reconnu quelle que soit la casse et malgre des espaces", async () => { + const res = await request(app).post("/api/auth/forgot-password").send({ email: ` ${EMAIL.toUpperCase()} ` }); + + expect(res.status).toBe(200); + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + expect(mailer.sendMail.mock.calls[0][0].to).toBe(EMAIL); + }); + + test("la reinitialisation valide un compte jamais verifie, qui peut ensuite se connecter", async () => { + await request(app).post("/api/auth/forgot-password").send({ email: NON_VERIFIE }); + await request(app) + .post(`/api/auth/reset-password/${tokenFromMail()}`) + .send({ password: "NouveauMdp1!", confirmPassword: "NouveauMdp1!" }) + .expect(200); + + const login = await request(app).post("/api/auth/login").send({ email: NON_VERIFIE, password: "NouveauMdp1!" }); + expect(login.status).toBe(200); + const enBase = await prisma.user.findUnique({ where: { email: NON_VERIFIE } }); + expect(enBase.isEmailVerified).toBe(true); + expect(enBase.emailVerifyToken).toBeNull(); + }); + + test("le profil connecte recoit le lien sur l adresse du compte, sans captcha", async () => { + await request(app).post("/api/auth/password-setup-link").expect(401); + + const login = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "NouveauMdp1!" }).expect(200); + const res = await request(app) + .post("/api/auth/password-setup-link") + .set("Authorization", `Bearer ${login.body.token}`); + + expect(res.status).toBe(200); + expect(mailer.sendMail.mock.calls[0][0].to).toBe(EMAIL); + expect(tokenFromMail()).toHaveLength(64); + }); +}); + +describe("Emails normalises a l inscription et a la connexion", () => { + test("un email saisi avec majuscules est stocke en minuscules et reconnu a la connexion", async () => { + const res = await request(app).post("/api/auth/register").send({ + firstname: "Nora", + lastname: "Blanc", + email: ` ${INSCRIT.replace("forgot", "FORGOT")} `, + password: "MotDePasse1!", + confirmPassword: "MotDePasse1!", + birthdate: "1990-05-14", + }); + expect(res.status).toBe(201); + + const enBase = await prisma.user.findUnique({ where: { email: INSCRIT } }); + expect(enBase).not.toBeNull(); + + // Même adresse avec une autre casse : doublon refusé. + const doublon = await request(app).post("/api/auth/register").send({ + firstname: "Nora", + lastname: "Blanc", + email: INSCRIT.toUpperCase(), + password: "MotDePasse1!", + confirmPassword: "MotDePasse1!", + }); + expect(doublon.status).toBe(409); + + await prisma.user.update({ where: { email: INSCRIT }, data: { isEmailVerified: true } }); + const login = await request(app).post("/api/auth/login").send({ email: INSCRIT.toUpperCase(), password: "MotDePasse1!" }); + expect(login.status).toBe(200); + }); +}); diff --git a/backend/tests/gamePhases.test.js b/backend/tests/gamePhases.test.js index b893306..432f081 100644 --- a/backend/tests/gamePhases.test.js +++ b/backend/tests/gamePhases.test.js @@ -87,6 +87,11 @@ describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => { log("phase :", vue.body.gameWindow.phase, "| participations ouvertes :", vue.body.gameWindow.isParticipationOpen); expect(vue.body.gameWindow.phase).toBe("NOT_CONFIGURED"); + // Route publique de la page d'accueil : sans configuration, pas de date, + // le front affiche alors les dates par défaut. + const publique = await request(app).get("/api/game/window").expect(200); + expect(publique.body).toMatchObject({ configured: false, startDate: null }); + const essai = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] }); log("un client tente de jouer ->", essai.status, essai.body.message); expect(essai.status).toBe(410); @@ -108,6 +113,12 @@ describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => { log("PUT game-settings ->", r.status, "| phase :", r.body.gameWindow.phase); expect(r.body.gameWindow.phase).toBe("SCHEDULED"); + // La page d'accueil reçoit aussitôt la nouvelle date, sans authentification. + const publique = await request(app).get("/api/game/window").expect(200); + expect(publique.body.configured).toBe(true); + expect(publique.body.startDate).toBe(futur); + expect(new Date(publique.body.participationDeadline).getTime()).toBe(new Date(futur).getTime() + 30 * DAY); + const anticipe = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] }); log("client joue avant ouverture ->", anticipe.status); log("message :", JSON.stringify(anticipe.body.message)); diff --git a/backend/tests/googlePassword.test.js b/backend/tests/googlePassword.test.js index b0ceb71..5992c5d 100644 --- a/backend/tests/googlePassword.test.js +++ b/backend/tests/googlePassword.test.js @@ -87,7 +87,7 @@ decrire("Inscription Google et definition du mot de passe", () => { 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" }); + .send({ password: "MonMotDePasse1!", confirmPassword: "AutreMotDePasse2!" }); expect(res.status).toBe(400); expect(res.body.message).toMatch(/ne correspondent pas/); @@ -105,14 +105,23 @@ decrire("Inscription Google et definition du mot de passe", () => { expect(res.body.message).toMatch(/8 caractères/); }); + test("un mot de passe sans caractere special est refuse", async () => { + const res = await request(app) + .post("/api/auth/reset-password/" + ctxToken) + .send({ password: "MonMotDePasse1", confirmPassword: "MonMotDePasse1" }); + + expect(res.status).toBe(400); + expect(res.body.message).toMatch(/caractère spécial/); + }); + 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" }) + .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); + 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. @@ -125,8 +134,16 @@ decrire("Inscription Google et definition du mot de passe", () => { const change = await request(app) .patch("/api/auth/change-password") .set("Authorization", "Bearer " + login.body.token) - .send({ currentPassword: "MonMotDePasse1", newPassword: "EncoreUnAutre2" }); + .send({ currentPassword: "MonMotDePasse1!", newPassword: "EncoreUnAutre2!" }); expect(change.status).toBe(200); + + // Même règle qu'à l'inscription pour le changement depuis le profil. + const sansSpecial = await request(app) + .patch("/api/auth/change-password") + .set("Authorization", "Bearer " + login.body.token) + .send({ currentPassword: "EncoreUnAutre2!", newPassword: "SansCaractere3" }); + expect(sansSpecial.status).toBe(400); + expect(sansSpecial.body.message).toMatch(/caractère spécial/); }); test("une seconde connexion Google ne renvoie pas de nouveau lien", async () => { diff --git a/backend/tests/register.mail.test.js b/backend/tests/register.mail.test.js index 463821c..6d2cb6c 100644 --- a/backend/tests/register.mail.test.js +++ b/backend/tests/register.mail.test.js @@ -15,7 +15,8 @@ const payload = { firstname: "Camille", lastname: "Martin", email: testEmail, - password: "Password123", + password: "Password123!", + confirmPassword: "Password123!", birthdate: "1990-05-14", gender: "Femme", }; diff --git a/backend/tests/registerPassword.test.js b/backend/tests/registerPassword.test.js new file mode 100644 index 0000000..f288491 --- /dev/null +++ b/backend/tests/registerPassword.test.js @@ -0,0 +1,62 @@ +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) })); + +const EMAIL = "register-password-test@test.local"; + +const payload = (password, confirmPassword = password) => ({ + firstname: "Camille", + lastname: "Martin", + email: EMAIL, + password, + confirmPassword, + birthdate: "1990-05-14", +}); + +async function cleanup() { + await prisma.user.deleteMany({ where: { email: EMAIL } }); +} + +beforeEach(cleanup); +afterAll(async () => { + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Regles du mot de passe a l inscription", () => { + test.each([ + ["trop court", "Abc12!", /8 caractères/], + ["sans caractere special", "MotDePasse123", /caractère spécial/], + ["accents et espaces ne comptent pas comme speciaux", "Mot de passé 123", /caractère spécial/], + ])("refuse un mot de passe %s", async (_, password, message) => { + const res = await request(app).post("/api/auth/register").send(payload(password)); + + expect(res.status).toBe(400); + expect(res.body.errors.password[0]).toMatch(message); + expect(await prisma.user.findUnique({ where: { email: EMAIL } })).toBeNull(); + }); + + test("refuse une confirmation differente", async () => { + const res = await request(app).post("/api/auth/register").send(payload("MotDePasse1!", "MotDePasse2!")); + + expect(res.status).toBe(400); + expect(res.body.errors.confirmPassword[0]).toMatch(/ne correspondent pas/); + }); + + test("refuse une inscription sans confirmation", async () => { + const { confirmPassword, ...sansConfirmation } = payload("MotDePasse1!"); + const res = await request(app).post("/api/auth/register").send(sansConfirmation); + + expect(res.status).toBe(400); + expect(res.body.errors.confirmPassword).toBeDefined(); + }); + + test("accepte 8 caracteres avec un caractere special", async () => { + const res = await request(app).post("/api/auth/register").send(payload("abcdefg#")); + + expect(res.status).toBe(201); + expect(await prisma.user.findUnique({ where: { email: EMAIL } })).not.toBeNull(); + }); +}); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 2601f0c..a4a39a6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,6 +7,8 @@ import Connexion from './pages/Connexion' import Inscription from './pages/Inscription' import AuthCallback from './pages/AuthCallback' import DefinirMotDePasse from './pages/DefinirMotDePasse' +import MotDePasseOublie from './pages/MotDePasseOublie' +import NouveauMotDePasse from './pages/NouveauMotDePasse' import Accueil from './pages/Accueil' import NotreConcept from './pages/NotreConcept' import ReglesJeu from './pages/ReglesJeu' @@ -33,7 +35,9 @@ export default function App() { <Route path="/connexion" element={<Connexion />} /> <Route path="/inscription" element={<Inscription />} /> <Route path="/auth/callback" element={<AuthCallback />} /> + <Route path="/mot-de-passe-oublie" element={<MotDePasseOublie />} /> <Route path="/definir-mot-de-passe/:token" element={<DefinirMotDePasse />} /> + <Route path="/nouveau-mot-de-passe" element={<NouveauMotDePasse />} /> {/* Pages publiques */} <Route path="/accueil" element={<Layout><Accueil /></Layout>} /> diff --git a/frontend/src/components/PasswordRules.jsx b/frontend/src/components/PasswordRules.jsx new file mode 100644 index 0000000..279deeb --- /dev/null +++ b/frontend/src/components/PasswordRules.jsx @@ -0,0 +1,21 @@ +import { PASSWORD_RULES } from '../utils/passwordRules' + +// Règles du mot de passe, cochées au fil de la saisie. À relier au champ via +// aria-describedby={id}. +export default function PasswordRules({ id, password }) { + return ( + <ul id={id} className="mt-2 space-y-1"> + {PASSWORD_RULES.map((rule) => { + const ok = rule.test(password) + return ( + <li key={rule.id} data-rule={rule.id} data-valid={ok} + className={`flex items-center gap-2 text-xs ${ok ? 'text-green-700' : 'text-gray-500'}`}> + <span aria-hidden="true">{ok ? '✓' : '○'}</span> + {rule.label} + <span className="sr-only">{ok ? ' : respecté' : ' : non respecté'}</span> + </li> + ) + })} + </ul> + ) +} diff --git a/frontend/src/components/ProtectedRoute.jsx b/frontend/src/components/ProtectedRoute.jsx index f78f05c..77b9fc4 100644 --- a/frontend/src/components/ProtectedRoute.jsx +++ b/frontend/src/components/ProtectedRoute.jsx @@ -1,5 +1,6 @@ import { Navigate } from 'react-router-dom' import { useAuth } from '../context/AuthContext' +import { homeFor, INITIAL_PASSWORD_PATH } from '../config/roles' export default function ProtectedRoute({ children, roles }) { const { user } = useAuth() @@ -8,9 +9,14 @@ export default function ProtectedRoute({ children, roles }) { return <Navigate to="/connexion" replace /> } + // Mot de passe provisoire : aucune page protégée tant qu'il n'est pas + // remplacé (le backend refuse de toute façon les appels à l'API). + if (user.mustChangePassword) { + return <Navigate to={INITIAL_PASSWORD_PATH} replace /> + } + if (roles && !roles.includes(user.role)) { - const redirects = { ADMIN: '/admin', EMPLOYEE: '/employe', CLIENT: '/mes-gains' } - return <Navigate to={redirects[user.role] || '/connexion'} replace /> + return <Navigate to={homeFor(user)} replace /> } return children diff --git a/frontend/src/components/admin/EmployeeDeliveries.jsx b/frontend/src/components/admin/EmployeeDeliveries.jsx new file mode 100644 index 0000000..3b8c851 --- /dev/null +++ b/frontend/src/components/admin/EmployeeDeliveries.jsx @@ -0,0 +1,288 @@ +import { useEffect, useRef, useState } from 'react' +import { getEmployeesApi, getEmployeeDeliveriesApi, getDeliveryByTicketCodeApi } from '../../services/admin' + +const STATUS_BADGES = { + DELIVERED: { label: 'Lot remis', className: 'bg-green-100 text-green-700' }, + PENDING: { label: 'En attente de retrait', className: 'bg-amber-100 text-amber-700' }, + CANCELLED: { label: 'Expiré', className: 'bg-gray-200 text-gray-700' }, + NOT_PLAYED: { label: 'Pas encore joué', className: 'bg-gray-100 text-gray-600' }, +} + +const INPUT_CLASS = + 'w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]' +const BUTTON_CLASS = + 'bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm' + +function formatDateTime(dateStr) { + return new Date(dateStr).toLocaleString('fr-FR', { + day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', + }) +} + +const fullName = (person) => `${person.firstname} ${person.lastname}` + +// Traçabilité des remises de lots : liste des employés, lots remis par chacun, +// et recherche inverse par numéro de ticket. +// `refreshKey` change quand un employé est créé, pour recharger la liste. +export default function EmployeeDeliveries({ refreshKey, onAuthError }) { + const [employees, setEmployees] = useState(null) + const [employeesError, setEmployeesError] = useState('') + const [filter, setFilter] = useState('') + + const [selected, setSelected] = useState(null) + const [deliveries, setDeliveries] = useState(null) + const [deliveriesLoading, setDeliveriesLoading] = useState(false) + const [deliveriesError, setDeliveriesError] = useState('') + const detailRef = useRef(null) + + const [ticketCode, setTicketCode] = useState('') + const [ticketResult, setTicketResult] = useState(null) + const [ticketLoading, setTicketLoading] = useState(false) + const [ticketError, setTicketError] = useState('') + + useEffect(() => { + getEmployeesApi() + .then((data) => { + setEmployees(data.employees) + setEmployeesError('') + }) + .catch((err) => { + if (!onAuthError(err)) setEmployeesError(err.message) + }) + }, [refreshKey, onAuthError]) + + const openEmployee = async (employee) => { + setSelected(employee) + setDeliveries(null) + setDeliveriesError('') + setDeliveriesLoading(true) + try { + const data = await getEmployeeDeliveriesApi(employee.id) + setDeliveries(data.deliveries) + } catch (err) { + if (!onAuthError(err)) setDeliveriesError(err.message) + } finally { + setDeliveriesLoading(false) + detailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) + } + } + + const handleTicketSearch = async (e) => { + e.preventDefault() + setTicketError('') + setTicketResult(null) + setTicketLoading(true) + try { + setTicketResult(await getDeliveryByTicketCodeApi(ticketCode)) + } catch (err) { + if (!onAuthError(err)) setTicketError(err.message) + } finally { + setTicketLoading(false) + } + } + + const needle = filter.trim().toLowerCase() + const visibleEmployees = (employees ?? []).filter((employee) => + !needle || `${fullName(employee)} ${employee.email}`.toLowerCase().includes(needle) + ) + + return ( + <div className="bg-white rounded-xl shadow p-4 sm:p-6 space-y-6"> + <div> + <h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Employés et remises des lots</h2> + <p className="text-xs sm:text-sm text-gray-500"> + Retrouvez quel employé a remis un lot et à quelle date, par numéro de ticket ou par employé. + </p> + </div> + + {/* ── Recherche par numéro de ticket ── */} + <div> + <form onSubmit={handleTicketSearch} className="flex flex-wrap items-end gap-3"> + <div className="flex-1 min-w-[200px]"> + <label htmlFor="delivery-ticket-code" className="block text-xs font-medium text-gray-700 mb-1"> + Numéro du ticket + </label> + <input + type="text" + id="delivery-ticket-code" + value={ticketCode} + onChange={(e) => setTicketCode(e.target.value)} + required + placeholder="Ex. A1B2C3D4E5" + className={`${INPUT_CLASS} uppercase placeholder:normal-case`} + /> + </div> + <button type="submit" disabled={ticketLoading || !ticketCode.trim()} className={BUTTON_CLASS}> + {ticketLoading ? 'Recherche…' : 'Rechercher'} + </button> + </form> + + {ticketError && <p className="text-red-500 text-xs sm:text-sm mt-3">{ticketError}</p>} + + {ticketResult && ( + <div className="mt-4 p-4 rounded-lg border border-gray-200 bg-gray-50 text-sm space-y-1.5" data-testid="ticket-result"> + <div className="flex items-center justify-between flex-wrap gap-2"> + <p className="font-mono font-semibold text-gray-800">{ticketResult.ticketCode}</p> + <span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${STATUS_BADGES[ticketResult.status]?.className}`}> + {STATUS_BADGES[ticketResult.status]?.label ?? ticketResult.status} + </span> + </div> + <p className="text-gray-600">Lot : <span className="font-medium text-gray-800">{ticketResult.lot.name}</span></p> + {ticketResult.client && ( + <p className="text-gray-600"> + Gagné par : <span className="font-medium text-gray-800">{fullName(ticketResult.client)}</span>{' '} + <span className="text-gray-500">({ticketResult.client.email})</span> + {ticketResult.playedAt && <> le {formatDateTime(ticketResult.playedAt)}</>} + </p> + )} + {ticketResult.deliveredBy ? ( + <p className="text-gray-600"> + Remis par :{' '} + <button + type="button" + onClick={() => openEmployee(ticketResult.deliveredBy)} + className="font-semibold text-[#006B6B] hover:underline" + > + {fullName(ticketResult.deliveredBy)} + </button>{' '} + le <span className="font-medium text-gray-800">{formatDateTime(ticketResult.deliveredAt)}</span> + </p> + ) : ( + ticketResult.status !== 'NOT_PLAYED' && ( + <p className="text-gray-500">Ce lot n'a pas encore été remis.</p> + ) + )} + </div> + )} + </div> + + {/* ── Liste des employés ── */} + <div className="pt-6 border-t border-gray-100"> + <div className="flex items-end justify-between flex-wrap gap-3 mb-3"> + <h3 className="text-sm font-semibold text-gray-800"> + Comptes employés + {employees && <span className="text-gray-500 font-normal"> - {employees.length}</span>} + </h3> + <div className="w-full sm:w-64"> + <label htmlFor="employee-filter" className="sr-only">Rechercher un employé</label> + <input + type="search" + id="employee-filter" + value={filter} + onChange={(e) => setFilter(e.target.value)} + placeholder="Rechercher un employé…" + className={INPUT_CLASS} + /> + </div> + </div> + + {employeesError && <p className="text-red-500 text-sm">{employeesError}</p>} + {!employees && !employeesError && <p className="text-gray-500 text-sm">Chargement…</p>} + + {employees && ( + <div className="overflow-x-auto -mx-4 sm:mx-0"> + <table className="w-full text-sm min-w-[520px]"> + <thead> + <tr className="text-left text-gray-500 border-b text-xs sm:text-sm"> + <th className="pb-2 px-4 sm:px-0">Nom</th> + <th className="pb-2 px-4 sm:px-0">Email</th> + <th className="pb-2 px-4 sm:px-0">Créé le</th> + <th className="pb-2 px-4 sm:px-0 text-center">Lots remis</th> + </tr> + </thead> + <tbody> + {visibleEmployees.length === 0 && ( + <tr> + <td colSpan={4} className="py-6 text-center text-gray-500 text-sm"> + {employees.length === 0 ? "Aucun compte employé pour l'instant" : 'Aucun employé ne correspond à la recherche'} + </td> + </tr> + )} + {visibleEmployees.map((employee) => ( + <tr key={employee.id} className={`border-b last:border-0 ${selected?.id === employee.id ? 'bg-[#006B6B]/5' : ''}`}> + <td className="py-3 px-4 sm:px-0 text-xs sm:text-sm"> + <button + type="button" + onClick={() => openEmployee(employee)} + className="font-semibold text-[#006B6B] hover:underline text-left" + > + {fullName(employee)} + </button> + {employee.mustChangePassword && ( + <span className="block text-[11px] text-amber-700">Première connexion en attente</span> + )} + </td> + <td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">{employee.email}</td> + <td className="py-3 px-4 sm:px-0 text-gray-500 text-xs"> + {new Date(employee.createdAt).toLocaleDateString('fr-FR')} + </td> + <td className="py-3 px-4 sm:px-0 text-center font-semibold text-gray-800">{employee.deliveredCount}</td> + </tr> + ))} + </tbody> + </table> + </div> + )} + </div> + + {/* ── Lots remis par l'employé sélectionné ── */} + {selected && ( + <div ref={detailRef} className="pt-6 border-t border-gray-100"> + <div className="flex items-center justify-between flex-wrap gap-2 mb-3"> + <h3 className="text-sm font-semibold text-gray-800"> + Lots remis par {fullName(selected)} + {deliveries && <span className="text-gray-500 font-normal"> — {deliveries.length}</span>} + </h3> + <button + type="button" + onClick={() => setSelected(null)} + className="border border-gray-200 text-gray-600 hover:bg-gray-50 font-semibold px-3 py-1.5 rounded-lg transition-colors text-xs" + > + Fermer + </button> + </div> + + {deliveriesLoading && <p className="text-gray-500 text-sm">Chargement…</p>} + {deliveriesError && <p className="text-red-500 text-sm">{deliveriesError}</p>} + + {deliveries && ( + <div className="overflow-x-auto -mx-4 sm:mx-0"> + <table className="w-full text-sm min-w-[560px]"> + <thead> + <tr className="text-left text-gray-500 border-b text-xs sm:text-sm"> + <th className="pb-2 px-4 sm:px-0">Ticket</th> + <th className="pb-2 px-4 sm:px-0">Lot</th> + <th className="pb-2 px-4 sm:px-0">Client</th> + <th className="pb-2 px-4 sm:px-0 text-right">Remis le</th> + </tr> + </thead> + <tbody> + {deliveries.length === 0 && ( + <tr> + <td colSpan={4} className="py-6 text-center text-gray-500 text-sm"> + Cet employé n'a encore remis aucun lot + </td> + </tr> + )} + {deliveries.map((delivery) => ( + <tr key={delivery.claimId} className="border-b last:border-0"> + <td className="py-3 px-4 sm:px-0 font-mono text-xs text-gray-800">{delivery.ticketCode}</td> + <td className="py-3 px-4 sm:px-0 text-xs sm:text-sm text-gray-800">{delivery.lot.name}</td> + <td className="py-3 px-4 sm:px-0 text-xs text-gray-500"> + {fullName(delivery.client)} + <span className="block">{delivery.client.email}</span> + </td> + <td className="py-3 px-4 sm:px-0 text-right text-xs text-gray-500 whitespace-nowrap"> + {formatDateTime(delivery.deliveredAt)} + </td> + </tr> + ))} + </tbody> + </table> + </div> + )} + </div> + )} + </div> + ) +} diff --git a/frontend/src/config/roles.js b/frontend/src/config/roles.js new file mode 100644 index 0000000..5073387 --- /dev/null +++ b/frontend/src/config/roles.js @@ -0,0 +1,10 @@ +// Page d'accueil de chaque rôle après connexion. +export const HOME_BY_ROLE = { ADMIN: '/admin', EMPLOYEE: '/employe', CLIENT: '/mes-gains' } + +// Page imposée tant qu'un mot de passe provisoire n'a pas été remplacé. +export const INITIAL_PASSWORD_PATH = '/nouveau-mot-de-passe' + +export function homeFor(user) { + if (user?.mustChangePassword) return INITIAL_PASSWORD_PATH + return HOME_BY_ROLE[user?.role] || '/connexion' +} diff --git a/frontend/src/hooks/useGameDates.js b/frontend/src/hooks/useGameDates.js new file mode 100644 index 0000000..76e3ac1 --- /dev/null +++ b/frontend/src/hooks/useGameDates.js @@ -0,0 +1,26 @@ +import { useEffect, useMemo, useState } from 'react' +import { getGameWindowApi } from '../services/game' +import { toGameDates } from '../utils/gameDates' + +// Dates du jeu-concours pour l'affichage public. Rend immédiatement les dates +// par défaut (2 → 31 octobre 2026), puis celles fixées par l'admin dès que +// l'API répond. Rechargées à chaque montage : une date modifiée dans le +// tableau de bord apparaît dès le retour sur la page. +export default function useGameDates() { + const [gameWindow, setGameWindow] = useState(null) + + useEffect(() => { + let active = true + getGameWindowApi() + .then((data) => { + if (active) setGameWindow(data) + }) + // API injoignable : les dates par défaut restent affichées. + .catch(() => {}) + return () => { + active = false + } + }, []) + + return useMemo(() => toGameDates(gameWindow), [gameWindow]) +} diff --git a/frontend/src/pages/Accueil.jsx b/frontend/src/pages/Accueil.jsx index c60e30e..bde32e0 100644 --- a/frontend/src/pages/Accueil.jsx +++ b/frontend/src/pages/Accueil.jsx @@ -1,6 +1,8 @@ import { useMemo } from 'react' import { Link } from 'react-router-dom' import usePageMeta from '../hooks/usePageMeta' +import useGameDates from '../hooks/useGameDates' +import { formatDay, formatRange } from '../utils/gameDates' import bannerImg from '../assets/img/banner-accueil.webp' const gains = [ @@ -38,10 +40,11 @@ function createTeaParticles(count) { })) } -const faqs = [ +// Les dates viennent du tableau de bord admin (voir useGameDates). +const buildFaqs = ({ start, lastPlayDay, lastClaimDay }) => [ { question: 'Comment obtenir un code ?', - answer: "Achetez pour plus de 49 € dans notre boutique Thé Tip Top de Nice, entre le 2 et le 31 octobre 2026 : un code unique à 10 caractères figure sur votre ticket de caisse ou votre facture.", + answer: `Achetez pour plus de 49 € dans notre boutique Thé Tip Top de Nice, du ${formatRange(start, lastPlayDay)} : un code unique à 10 caractères figure sur votre ticket de caisse ou votre facture.`, }, { question: 'Quels sont les lots à gagner ?', @@ -62,7 +65,7 @@ const faqs = [ }, { question: 'Jusqu\'à quand puis-je jouer ?', - answer: 'La saisie des codes est ouverte du 2 au 31 octobre 2026 inclus. Les lots sont ensuite à retirer jusqu\'au 30 novembre 2026.', + answer: `La saisie des codes est ouverte du ${formatRange(start, lastPlayDay)} inclus. Les lots sont ensuite à retirer jusqu'au ${formatDay(lastClaimDay)}.`, }, { question: 'Que faire si mon code n\'est pas reconnu ?', @@ -70,11 +73,11 @@ const faqs = [ }, { question: 'Où retirer mon lot ?', - answer: 'Dans notre boutique de Nice, sur présentation de votre compte utilisateur validé et de la confirmation de gain affichée dans votre espace « Mes gains », jusqu\'au 30 novembre 2026.', + answer: `Dans notre boutique de Nice, sur présentation de votre compte utilisateur validé et de la confirmation de gain affichée dans votre espace « Mes gains », jusqu'au ${formatDay(lastClaimDay)}.`, }, ] -const faqJsonLd = { +const buildFaqJsonLd = (faqs) => ({ '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: faqs.map((faq) => ({ @@ -85,12 +88,18 @@ const faqJsonLd = { text: faq.plainAnswer || faq.answer, }, })), -} +}) export default function Accueil() { + const gameDates = useGameDates() + const period = formatRange(gameDates.start, gameDates.lastPlayDay) + const faqs = useMemo(() => buildFaqs(gameDates), [gameDates]) + const faqJsonLd = useMemo(() => buildFaqJsonLd(faqs), [faqs]) + const bannerAlt = `Jeu-concours 100 % Gagnant Thé Tip Top, du ${period} : tentez de gagner un an de thé d'une valeur de 360 € pour l'ouverture de notre 10e boutique à Nice.` + usePageMeta({ - title: 'Jeu-concours 100 % Gagnant du 2 au 31 octobre | Thé Tip Top', - description: "Du 2 au 31 octobre, tout ticket de plus de 49 € de notre boutique de Nice porte un code 100 % gagnant. Saisissez le vôtre et repartez avec votre lot.", + title: `Jeu-concours 100 % Gagnant du ${formatRange(gameDates.start, gameDates.lastPlayDay, { withYear: false })} | Thé Tip Top`, + description: `Du ${formatRange(gameDates.start, gameDates.lastPlayDay, { withYear: false })}, tout ticket de plus de 49 € de notre boutique de Nice porte un code 100 % gagnant. Saisissez le vôtre et repartez avec votre lot.`, }) const teaParticles = useMemo(() => createTeaParticles(30), []) @@ -130,7 +139,7 @@ export default function Accueil() { <div className="static lg:absolute lg:left-0 lg:top-10 max-w-full lg:max-w-md mb-6 lg:mb-0"> <p className="inline-block text-sm sm:text-base font-bold uppercase tracking-widest text-amber-300 border border-white/70 rounded-lg px-3.5 py-2 mb-3"> - Du 02 au 31 octobre 2026 + Du {formatRange(gameDates.start, gameDates.lastPlayDay, { padDay: true })} </p> <h1 className="text-3xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-[1.05]"> Jeu-concours{' '} @@ -142,13 +151,13 @@ export default function Accueil() { <div className="relative aspect-[16/10] sm:aspect-[21/9] overflow-hidden lg:hidden"> <img src={bannerImg} - alt="Jeu-concours 100 % Gagnant Thé Tip Top, du 2 au 31 octobre 2026 : tentez de gagner un an de thé d'une valeur de 360 € pour l'ouverture de notre 10e boutique à Nice." + alt={bannerAlt} className="absolute inset-y-0 right-0 w-[210%] sm:w-[130%] max-w-none h-full object-cover object-right animate-banner-image" /> </div> <img src={bannerImg} - alt="Jeu-concours 100 % Gagnant Thé Tip Top, du 2 au 31 octobre 2026 : tentez de gagner un an de thé d'une valeur de 360 € pour l'ouverture de notre 10e boutique à Nice." + alt={bannerAlt} width={2048} height={1152} className="hidden lg:block lg:absolute lg:right-[-2%] lg:top-1/2 lg:-translate-y-1/2 lg:h-[145%] xl:h-[160%] lg:w-auto lg:max-w-none object-contain drop-shadow-2xl animate-banner-image" diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 43380c7..1412766 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -1,6 +1,8 @@ import { useState, useEffect } from 'react' import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi, createEmployeeApi } from '../services/admin' +import EmployeeDeliveries from '../components/admin/EmployeeDeliveries' import { useHandleAuthError } from '../hooks/useHandleAuthError' +import { formatDay, formatRange, gameDatesFromStart } from '../utils/gameDates' function StatCard({ label, value, sub }) { return ( @@ -38,6 +40,13 @@ const PHASE_TITLES = { CLOSED: 'Jeu-concours terminé', } +// Valeur attendue par <input type="datetime-local"> : heure locale, sans fuseau. +function toDateTimeLocalValue(dateStr) { + const date = new Date(dateStr) + const pad = (n) => String(n).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` +} + function formatDate(dateStr) { return new Date(dateStr).toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric', @@ -68,15 +77,19 @@ export default function Admin() { const [emailingError, setEmailingError] = useState('') // Création d'un compte employé - const [employeeForm, setEmployeeForm] = useState({ firstname: '', lastname: '', email: '', password: '' }) + const [employeeForm, setEmployeeForm] = useState({ firstname: '', lastname: '', email: '' }) const [employeeSaving, setEmployeeSaving] = useState(false) const [employeeError, setEmployeeError] = useState('') const [employeeOk, setEmployeeOk] = useState('') + // Incrémenté à chaque création pour recharger la liste des employés. + const [employeesVersion, setEmployeesVersion] = useState(0) useEffect(() => { Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()]) .then(([s, u, w]) => { setStats(s) + // Le champ part de la date enregistrée, celle affichée sur l'accueil. + if (s.gameWindow?.startDate) setStartDateInput(toDateTimeLocalValue(s.gameWindow.startDate)) setUsers(u.users) setWinner(w) }) @@ -96,8 +109,8 @@ export default function Admin() { // convertit en ISO pour que le serveur reçoive un instant sans ambiguïté. const data = await setGameSettingsApi(new Date(startDateInput).toISOString()) setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev)) - setSettingsOk(data.message) - setStartDateInput('') + setSettingsOk(`${data.message} La page d'accueil affiche désormais ces dates.`) + setStartDateInput(toDateTimeLocalValue(data.gameWindow.startDate)) } catch (err) { if (!handleAuthError(err)) setSettingsError(err.message) } finally { @@ -157,8 +170,9 @@ export default function Admin() { setEmployeeSaving(true) try { const data = await createEmployeeApi(employeeForm) - setEmployeeOk(`Compte employé créé pour ${data.employee.firstname} ${data.employee.lastname} (${data.employee.email}).`) - setEmployeeForm({ firstname: '', lastname: '', email: '', password: '' }) + setEmployeeOk(`Compte créé pour ${data.employee.firstname} ${data.employee.lastname} : un email avec un mot de passe provisoire a été envoyé à ${data.employee.email}.`) + setEmployeeForm({ firstname: '', lastname: '', email: '' }) + setEmployeesVersion((v) => v + 1) } catch (err) { if (!handleAuthError(err)) setEmployeeError(err.message) } finally { @@ -180,6 +194,15 @@ export default function Admin() { const winnerUser = winner?.winner?.user + // Aperçu en direct des dates publiques pour la date saisie. + const startInputDate = startDateInput ? new Date(startDateInput) : null + const startPreview = startInputDate && !Number.isNaN(startInputDate.getTime()) + ? gameDatesFromStart(startInputDate) + : null + const savedStart = stats?.gameWindow?.startDate + const startPreviewUnsaved = Boolean(startPreview) && + (!savedStart || toDateTimeLocalValue(savedStart) !== startDateInput) + return ( <div className="min-h-screen bg-gray-50 p-4 sm:p-6"> <div className="max-w-5xl mx-auto space-y-6"> @@ -236,6 +259,17 @@ export default function Admin() { </button> </form> + {startPreview && ( + <p className="text-xs text-gray-700 mt-3" data-testid="homepage-dates-preview"> + Affiché sur la page d'accueil :{' '} + <span className="font-semibold"> + Du {formatRange(startPreview.start, startPreview.lastPlayDay, { padDay: true })} + </span> + , retrait des lots jusqu'au <span className="font-semibold">{formatDay(startPreview.lastClaimDay)}</span> + {startPreviewUnsaved && <span className="text-amber-700"> (non enregistré)</span>} + </p> + )} + <p className="text-xs text-gray-500 mt-2"> Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots. </p> @@ -392,6 +426,7 @@ export default function Admin() { <h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Créer un compte employé</h2> <p className="text-xs sm:text-sm text-gray-500 mb-4"> Donne accès à l'espace employé (remise des lots en boutique). L'admin ne gère pas les remises lui-même. + L'employé reçoit par email un mot de passe provisoire, qu'il devra changer à sa première connexion. </p> <form onSubmit={handleCreateEmployee} className="grid sm:grid-cols-2 gap-3"> @@ -428,19 +463,6 @@ export default function Admin() { className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" /> </div> - <div> - <label htmlFor="employee-password" className="block text-xs font-medium text-gray-700 mb-1">Mot de passe</label> - <input - type="password" - id="employee-password" - value={employeeForm.password} - onChange={(e) => setEmployeeForm({ ...employeeForm, password: e.target.value })} - required - minLength={8} - placeholder="8 caractères minimum" - className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" - /> - </div> <div className="sm:col-span-2 flex items-center gap-3 pt-1"> <button @@ -456,6 +478,9 @@ export default function Admin() { </form> </div> + {/* ── Employés et remises des lots ── */} + <EmployeeDeliveries refreshKey={employeesVersion} onAuthError={handleAuthError} /> + {/* ── Tirage au sort ── */} <div className="bg-white rounded-xl shadow p-4 sm:p-6"> <h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Grand tirage au sort</h2> diff --git a/frontend/src/pages/AuthCallback.jsx b/frontend/src/pages/AuthCallback.jsx index 16f6e7f..4843c7e 100644 --- a/frontend/src/pages/AuthCallback.jsx +++ b/frontend/src/pages/AuthCallback.jsx @@ -1,6 +1,7 @@ import { useEffect, useRef } from 'react' import { useNavigate, useSearchParams, Link } from 'react-router-dom' import { useAuth } from '../context/AuthContext' +import { homeFor } from '../config/roles' // 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. @@ -40,10 +41,7 @@ export default function AuthCallback() { handled.current = true 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 }) + navigate(homeFor(user), { replace: true }) }, [user, token, login, navigate]) if (error) { diff --git a/frontend/src/pages/Connexion.jsx b/frontend/src/pages/Connexion.jsx index 9d3613e..78763f6 100644 --- a/frontend/src/pages/Connexion.jsx +++ b/frontend/src/pages/Connexion.jsx @@ -5,6 +5,7 @@ import { useAuth } from '../context/AuthContext' import usePageMeta from '../hooks/usePageMeta' import Captcha from '../components/Captcha' import { CAPTCHA_ENABLED } from '../config/captcha' +import { homeFor } from '../config/roles' export default function Connexion() { usePageMeta({ @@ -39,9 +40,9 @@ export default function Connexion() { try { 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') + // Un employé qui se connecte avec son mot de passe provisoire est + // envoyé directement sur le choix de son nouveau mot de passe. + navigate(homeFor(user)) } catch (err) { setError(err.message) // Le jeton a été consommé par le backend : on en redemande un. @@ -119,9 +120,13 @@ export default function Connexion() { </button> </div> <div className="text-right mt-1"> - <button type="button" className="text-xs text-gray-500 hover:text-[#006B6B] hover:underline"> + <Link + to="/mot-de-passe-oublie" + state={{ email: form.email }} + className="text-xs text-gray-500 hover:text-[#006B6B] hover:underline" + > Mot de passe oublié ? - </button> + </Link> </div> </div> diff --git a/frontend/src/pages/DefinirMotDePasse.jsx b/frontend/src/pages/DefinirMotDePasse.jsx index 0fab51b..8c11c7a 100644 --- a/frontend/src/pages/DefinirMotDePasse.jsx +++ b/frontend/src/pages/DefinirMotDePasse.jsx @@ -1,6 +1,8 @@ import { useState } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { resetPasswordApi } from '../services/auth' +import PasswordRules from '../components/PasswordRules' +import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules' // 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, @@ -21,8 +23,8 @@ export default function DefinirMotDePasse() { e.preventDefault() setError('') - if (form.password.length < 8) { - setError('Le mot de passe doit contenir au moins 8 caractères.') + if (!isPasswordValid(form.password)) { + setError(PASSWORD_RULES_ERROR) return } if (form.password !== form.confirmPassword) { @@ -58,15 +60,22 @@ export default function DefinirMotDePasse() { 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> + <h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Nouveau mot de passe</h1> + {/* Texte neutre : la page sert au mot de passe oublié comme au + premier mot de passe d'un compte Google. */} <p className="text-gray-500 mt-1 text-sm"> - Choisissez un mot de passe pour accéder à votre compte sans passer par Google. + Choisissez le mot de passe que vous utiliserez pour vous connecter avec votre adresse email. </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"> + <div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm" role="alert"> {error} + {/invalide ou expiré/i.test(error) && ( + <Link to="/mot-de-passe-oublie" className="block mt-2 font-medium text-[#006B6B] hover:underline"> + Demander un nouveau lien + </Link> + )} </div> )} @@ -82,10 +91,12 @@ export default function DefinirMotDePasse() { value={form.password} onChange={handleChange} required - minLength={8} - placeholder="8 caractères minimum" + autoComplete="new-password" + aria-describedby="password-rules" + placeholder="Votre nouveau mot de passe" className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" /> + <PasswordRules id="password-rules" password={form.password} /> </div> <div> diff --git a/frontend/src/pages/Inscription.jsx b/frontend/src/pages/Inscription.jsx index d4df572..163b892 100644 --- a/frontend/src/pages/Inscription.jsx +++ b/frontend/src/pages/Inscription.jsx @@ -5,6 +5,8 @@ import { useAuth } from '../context/AuthContext' import usePageMeta from '../hooks/usePageMeta' import Captcha from '../components/Captcha' import { CAPTCHA_ENABLED } from '../config/captcha' +import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules' +import PasswordRules from '../components/PasswordRules' // Une naissance ne peut pas être dans le futur : borne haute du sélecteur. const AUJOURD_HUI = new Date().toISOString().split('T')[0] @@ -42,6 +44,7 @@ export default function Inscription() { lastName: '', firstName: '', password: '', + confirmPassword: '', birthdate: '', gender: '', }) @@ -63,6 +66,9 @@ export default function Inscription() { const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value }) + // Signalé dès que la confirmation est saisie, sans attendre l'envoi. + const passwordMismatch = form.confirmPassword !== '' && form.password !== form.confirmPassword + // 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 || '' @@ -79,6 +85,16 @@ export default function Inscription() { return } + if (!isPasswordValid(form.password)) { + setError(PASSWORD_RULES_ERROR) + return + } + + if (form.password !== form.confirmPassword) { + setError('Les deux mots de passe ne correspondent pas.') + return + } + setLoading(true) try { await registerApi({ @@ -86,6 +102,7 @@ export default function Inscription() { firstname: form.firstName, lastname: form.lastName, password: form.password, + confirmPassword: form.confirmPassword, birthdate: form.birthdate, gender: form.gender, marketingOptIn, @@ -256,6 +273,7 @@ export default function Inscription() { <label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label> <div className="relative"> <input type={showPassword ? 'text' : 'password'} id="reg-password" name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe" + autoComplete="new-password" aria-describedby="reg-password-rules" className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" /> <button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600" aria-label="Afficher/masquer"> @@ -264,6 +282,17 @@ export default function Inscription() { </div> </button> </div> + <PasswordRules id="reg-password-rules" password={form.password} /> + </div> + + <div> + <label htmlFor="reg-confirm-password" 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="reg-confirm-password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required placeholder="Saisissez-le à nouveau" + autoComplete="new-password" aria-invalid={passwordMismatch} aria-describedby={passwordMismatch ? 'reg-confirm-password-error' : undefined} + className={`w-full border rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 ${passwordMismatch ? 'border-red-300 focus:ring-red-400 focus:border-red-400' : 'border-gray-200 focus:ring-[#006B6B] focus:border-[#006B6B]'}`} /> + {passwordMismatch && ( + <p id="reg-confirm-password-error" className="text-xs text-red-600 mt-1">Les deux mots de passe ne correspondent pas.</p> + )} </div> <label className="flex items-start gap-3 cursor-pointer"> diff --git a/frontend/src/pages/MotDePasseOublie.jsx b/frontend/src/pages/MotDePasseOublie.jsx new file mode 100644 index 0000000..b10e53e --- /dev/null +++ b/frontend/src/pages/MotDePasseOublie.jsx @@ -0,0 +1,117 @@ +import { useRef, useState } from 'react' +import { Link, useLocation } from 'react-router-dom' +import { forgotPasswordApi } from '../services/auth' +import usePageMeta from '../hooks/usePageMeta' +import Captcha from '../components/Captcha' +import { CAPTCHA_ENABLED } from '../config/captcha' + +// Demande d'un lien de réinitialisation. La réponse est volontairement la même +// que le compte existe ou non, pour ne pas révéler quels emails sont inscrits. +export default function MotDePasseOublie() { + usePageMeta({ + title: 'Mot de passe oublié | Thé Tip Top', + description: 'Recevez par email un lien pour choisir un nouveau mot de passe pour votre compte Thé Tip Top.', + }) + + // L'email déjà saisi sur la page de connexion est repris. + const { state } = useLocation() + const [email, setEmail] = useState(state?.email ?? '') + const [sent, setSent] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const [captchaToken, setCaptchaToken] = useState('') + const captchaRef = useRef() + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + setLoading(true) + try { + const data = await forgotPasswordApi({ email, captchaToken }) + setSent(data.message) + } catch (err) { + setError(err.message) + // Le jeton a été consommé par le backend : on en redemande un. + captchaRef.current?.reset() + setCaptchaToken('') + } finally { + setLoading(false) + } + } + + if (sent) { + return ( + <div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto text-center"> + <h1 className="text-3xl font-bold italic font-serif text-[#006B6B] mb-4">Vérifiez vos emails</h1> + <div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm" role="status"> + {sent} + </div> + <p className="text-sm text-gray-500 mb-6"> + Le lien est valable 30 minutes. Pensez à regarder dans vos courriers indésirables. + </p> + <button + type="button" + onClick={() => { setSent(''); setCaptchaToken('') }} + className="text-sm text-gray-500 hover:text-[#006B6B] hover:underline mb-3" + > + Je n'ai rien reçu, renvoyer un lien + </button> + <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 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 oublié</h1> + <p className="text-gray-500 mt-2 text-sm"> + Saisissez l'adresse email de votre compte : nous vous enverrons un lien pour choisir un nouveau mot de passe. + </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" role="alert"> + {error} + </div> + )} + + <form onSubmit={handleSubmit} className="space-y-5"> + <div> + <label htmlFor="forgot-email" className="block text-sm font-medium text-gray-800 mb-1"> + Adresse email <span className="text-gray-800">*</span> + </label> + <input + type="email" + id="forgot-email" + name="email" + value={email} + onChange={(e) => setEmail(e.target.value)} + required + autoComplete="email" + placeholder="Votre email" + className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" + /> + </div> + + <Captcha ref={captchaRef} onToken={setCaptchaToken} /> + + <button + type="submit" + 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 ? 'Envoi…' : 'Recevoir le lien'} + </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> + ) +} diff --git a/frontend/src/pages/NouveauMotDePasse.jsx b/frontend/src/pages/NouveauMotDePasse.jsx new file mode 100644 index 0000000..bc6edbe --- /dev/null +++ b/frontend/src/pages/NouveauMotDePasse.jsx @@ -0,0 +1,139 @@ +import { useState } from 'react' +import { Navigate, useNavigate } from 'react-router-dom' +import { setInitialPasswordApi } from '../services/auth' +import { useAuth } from '../context/AuthContext' +import { homeFor } from '../config/roles' +import PasswordRules from '../components/PasswordRules' +import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules' + +// Première connexion d'un employé créé par un admin : le mot de passe reçu +// par email est provisoire, il doit être remplacé avant tout accès. +export default function NouveauMotDePasse() { + const navigate = useNavigate() + const { user, login, logout } = useAuth() + + const [form, setForm] = useState({ password: '', confirmPassword: '' }) + const [showPassword, setShowPassword] = useState(false) + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + + if (!user) return <Navigate to="/connexion" replace /> + if (!user.mustChangePassword) return <Navigate to={homeFor(user)} replace /> + + const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value }) + + const handleSubmit = async (e) => { + e.preventDefault() + setError('') + + if (!isPasswordValid(form.password)) { + setError(PASSWORD_RULES_ERROR) + return + } + if (form.password !== form.confirmPassword) { + setError('Les deux mots de passe ne correspondent pas.') + return + } + + setLoading(true) + try { + await setInitialPasswordApi(form) + const updatedUser = { ...user, mustChangePassword: false } + login(updatedUser, localStorage.getItem('token')) + navigate(homeFor(updatedUser), { replace: true }) + } catch (err) { + if (err.status === 401) { + logout() + navigate('/connexion', { replace: true }) + return + } + setError(err.message) + setLoading(false) + } + } + + const handleLogout = () => { + logout() + navigate('/connexion', { replace: true }) + } + + 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]">Nouveau mot de passe</h1> + <p className="text-gray-500 mt-2 text-sm"> + Bienvenue {user.firstname} ! Vous vous êtes connecté avec un mot de passe provisoire. + Choisissez votre propre mot de passe pour accéder à votre espace. + </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" role="alert"> + {error} + </div> + )} + + <form onSubmit={handleSubmit} className="space-y-5"> + <div> + <label htmlFor="new-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="new-password" + name="password" + value={form.password} + onChange={handleChange} + required + autoComplete="new-password" + aria-describedby="new-password-rules" + placeholder="Votre nouveau mot de passe" + className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" + /> + <PasswordRules id="new-password-rules" password={form.password} /> + </div> + + <div> + <label htmlFor="confirm-new-password" 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="confirm-new-password" + name="confirmPassword" + value={form.confirmPassword} + onChange={handleChange} + required + autoComplete="new-password" + placeholder="Saisissez-le à nouveau" + className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 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-5 h-5 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"> + <button type="button" onClick={handleLogout} className="text-gray-700 font-medium hover:underline"> + Se déconnecter + </button> + </p> + </div> + ) +} diff --git a/frontend/src/pages/Profile.jsx b/frontend/src/pages/Profile.jsx index acfe59f..2f28ad6 100644 --- a/frontend/src/pages/Profile.jsx +++ b/frontend/src/pages/Profile.jsx @@ -1,8 +1,10 @@ import { useState, useEffect, useRef } from 'react' import { useLocation } from 'react-router-dom' import UserNav from '../components/UserNav' -import { changePasswordApi, meApi, forgotPasswordApi } from '../services/auth' +import { changePasswordApi, meApi, sendPasswordSetupLinkApi } from '../services/auth' import { useHandleAuthError } from '../hooks/useHandleAuthError' +import PasswordRules from '../components/PasswordRules' +import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules' export default function Profile() { const user = JSON.parse(localStorage.getItem('user') || '{}') @@ -47,7 +49,7 @@ export default function Profile() { setLinkError('') setLinkSending(true) try { - await forgotPasswordApi(user.email) + await sendPasswordSetupLinkApi() setLinkSent(true) } catch (e) { if (!handleAuthError(e)) setLinkError(e.message) @@ -67,8 +69,8 @@ export default function Profile() { setPwError('Les nouveaux mots de passe ne correspondent pas.') return } - if (pwForm.newPassword.length < 8) { - setPwError('Le nouveau mot de passe doit faire au moins 8 caractères.') + if (!isPasswordValid(pwForm.newPassword)) { + setPwError(PASSWORD_RULES_ERROR) return } @@ -193,6 +195,8 @@ export default function Profile() { value={pwForm[name]} onChange={handlePwChange} required + autoComplete={name === 'currentPassword' ? 'current-password' : 'new-password'} + aria-describedby={name === 'newPassword' ? 'profile-password-rules' : undefined} placeholder="••••••••" className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-11 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" /> @@ -205,6 +209,9 @@ export default function Profile() { <EyeIcon visible={showPw[key]} /> </button> </div> + {name === 'newPassword' && ( + <PasswordRules id="profile-password-rules" password={pwForm.newPassword} /> + )} </div> ))} diff --git a/frontend/src/services/admin.js b/frontend/src/services/admin.js index 1228ce3..ca32afc 100644 --- a/frontend/src/services/admin.js +++ b/frontend/src/services/admin.js @@ -61,11 +61,34 @@ export async function getWinnerApi() { return data } -export async function createEmployeeApi({ firstname, lastname, email, password }) { +export async function getEmployeesApi() { + const res = await fetch(`${BASE}/employees`, { headers: authHeaders() }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || 'Erreur lors du chargement des employés.', res.status) + return data +} + +export async function getEmployeeDeliveriesApi(employeeId) { + const res = await fetch(`${BASE}/employees/${employeeId}/deliveries`, { headers: authHeaders() }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || 'Erreur lors du chargement des remises.', res.status) + return data +} + +export async function getDeliveryByTicketCodeApi(code) { + const res = await fetch(`${BASE}/deliveries/${encodeURIComponent(code.trim())}`, { headers: authHeaders() }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || 'Erreur lors de la recherche du ticket.', res.status) + return data +} + +// Pas de mot de passe : le serveur en génère un provisoire et l'envoie par +// email à l'employé. +export async function createEmployeeApi({ firstname, lastname, email }) { const res = await fetch(`${BASE}/employees`, { method: 'POST', headers: authHeaders(), - body: JSON.stringify({ firstname, lastname, email, password }), + body: JSON.stringify({ firstname, lastname, email }), }) const data = await res.json() if (!res.ok) throw apiError(data.message || "Erreur lors de la création de l'employé.", res.status) diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js index 7537c9c..6c23f72 100644 --- a/frontend/src/services/auth.js +++ b/frontend/src/services/auth.js @@ -23,6 +23,7 @@ export async function registerApi({ firstname, lastname, password, + confirmPassword, birthdate, gender, marketingOptIn, @@ -38,6 +39,7 @@ export async function registerApi({ firstname, lastname, password, + confirmPassword, birthdate: birthdate || undefined, gender: gender || undefined, marketingOptIn: marketingOptIn ?? undefined, @@ -66,11 +68,23 @@ export async function verifyCodeApi({ email, code }) { return data } -export async function forgotPasswordApi(email) { +export async function forgotPasswordApi({ email, captchaToken }) { const res = await fetch(`${BASE}/forgot-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), + body: JSON.stringify({ email, captchaToken: captchaToken || undefined }), + }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status) + return data +} + +// Utilisateur connecté : lien envoyé à l'adresse de son compte. +export async function sendPasswordSetupLinkApi() { + const token = localStorage.getItem('token') + const res = await fetch(`${BASE}/password-setup-link`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, }) const data = await res.json() if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status) @@ -109,6 +123,21 @@ export async function deleteAccountApi() { return data } +export async function setInitialPasswordApi({ password, confirmPassword }) { + const token = localStorage.getItem('token') + const res = await fetch(`${BASE}/initial-password`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ password, confirmPassword }), + }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || "Erreur lors de l'enregistrement du mot de passe.", res.status) + return data +} + export async function changePasswordApi({ currentPassword, newPassword }) { const token = localStorage.getItem('token') const res = await fetch(`${BASE}/change-password`, { diff --git a/frontend/src/services/game.js b/frontend/src/services/game.js index fc81d9c..2126aa1 100644 --- a/frontend/src/services/game.js +++ b/frontend/src/services/game.js @@ -8,6 +8,14 @@ function apiError(message, status) { return err } +// Public : dates du jeu-concours fixées par l'admin. +export async function getGameWindowApi() { + const res = await fetch(`${BASE}/window`) + const data = await res.json() + if (!res.ok) throw apiError(data.message || 'Erreur lors de la lecture des dates du jeu.', res.status) + return data +} + export async function myGainsApi() { const token = localStorage.getItem('token') const res = await fetch(`${BASE}/my-gains`, { diff --git a/frontend/src/setupTests.js b/frontend/src/setupTests.js index d0879b7..83b2a45 100644 --- a/frontend/src/setupTests.js +++ b/frontend/src/setupTests.js @@ -2,6 +2,9 @@ import '@testing-library/jest-dom' import { afterEach } from 'vitest' import { cleanup } from '@testing-library/react' +// jsdom n'implémente pas le défilement, présent dans tous les navigateurs. +Element.prototype.scrollIntoView ??= function scrollIntoView() {} + afterEach(() => { cleanup() localStorage.clear() diff --git a/frontend/src/test/fonctionnel/Accueil.test.jsx b/frontend/src/test/fonctionnel/Accueil.test.jsx new file mode 100644 index 0000000..e27ba43 --- /dev/null +++ b/frontend/src/test/fonctionnel/Accueil.test.jsx @@ -0,0 +1,41 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import Accueil from '../../pages/Accueil' +import { getGameWindowApi } from '../../services/game' +import { renderWithProviders } from '../renderWithProviders' + +vi.mock('../../services/game', () => ({ + getGameWindowApi: vi.fn(), +})) + +describe('Accueil - dates du jeu-concours', () => { + beforeEach(() => vi.clearAllMocks()) + + it("affiche les dates fixées dans le tableau de bord admin", async () => { + getGameWindowApi.mockResolvedValue({ + configured: true, + phase: 'SCHEDULED', + startDate: '2026-11-14T23:00:00.000Z', + participationDeadline: '2026-12-14T23:00:00.000Z', + claimDeadline: '2027-01-13T23:00:00.000Z', + }) + + renderWithProviders(<Accueil />, { route: '/accueil' }) + + expect(await screen.findByText('Du 15 novembre au 14 décembre 2026')).toBeInTheDocument() + expect(screen.getByText(/ouverte du 15 novembre au 14 décembre 2026 inclus/)).toBeInTheDocument() + expect(screen.getByText(/retirer jusqu'au 13 janvier 2027/)).toBeInTheDocument() + expect(document.title).toBe('Jeu-concours 100 % Gagnant du 15 novembre au 14 décembre | Thé Tip Top') + }) + + it("affiche les dates par défaut (2 → 31 octobre 2026) si l'API ne répond pas", async () => { + getGameWindowApi.mockRejectedValue(new Error('réseau')) + + renderWithProviders(<Accueil />, { route: '/accueil' }) + + expect(screen.getByText('Du 02 au 31 octobre 2026')).toBeInTheDocument() + expect(screen.getByText(/retirer jusqu'au 30 novembre 2026/)).toBeInTheDocument() + await vi.waitFor(() => expect(getGameWindowApi).toHaveBeenCalled()) + expect(screen.getByText('Du 02 au 31 octobre 2026')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/fonctionnel/DefinirMotDePasse.test.jsx b/frontend/src/test/fonctionnel/DefinirMotDePasse.test.jsx new file mode 100644 index 0000000..faf99b4 --- /dev/null +++ b/frontend/src/test/fonctionnel/DefinirMotDePasse.test.jsx @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import DefinirMotDePasse from '../../pages/DefinirMotDePasse' +import { resetPasswordApi } from '../../services/auth' +import { renderWithProviders } from '../renderWithProviders' + +vi.mock('../../services/auth', () => ({ + resetPasswordApi: vi.fn(), +})) + +async function remplir(password, confirmPassword = password) { + await userEvent.type(screen.getByLabelText(/^nouveau mot de passe/i), password) + await userEvent.type(screen.getByLabelText(/^confirmer/i), confirmPassword) + await userEvent.click(screen.getByRole('button', { name: /enregistrer/i })) +} + +describe('DefinirMotDePasse', () => { + beforeEach(() => vi.clearAllMocks()) + + it('affiche les règles du mot de passe', () => { + const { container } = renderWithProviders(<DefinirMotDePasse />, { route: '/definir-mot-de-passe/abc' }) + + expect(container.querySelector('[data-rule="length"]')).toBeInTheDocument() + expect(container.querySelector('[data-rule="special"]')).toBeInTheDocument() + }) + + it("refuse un mot de passe sans caractère spécial sans appeler l'API", async () => { + renderWithProviders(<DefinirMotDePasse />, { route: '/definir-mot-de-passe/abc' }) + + await remplir('MonMotDePasse1') + + expect(screen.getByText(/dont un caractère spécial/i)).toBeInTheDocument() + expect(resetPasswordApi).not.toHaveBeenCalled() + }) + + it('propose de demander un nouveau lien quand celui-ci a expiré', async () => { + resetPasswordApi.mockRejectedValue(new Error('Lien de réinitialisation invalide ou expiré.')) + renderWithProviders(<DefinirMotDePasse />, { route: '/definir-mot-de-passe/abc' }) + + await remplir('MonMotDePasse1!') + + expect(await screen.findByRole('link', { name: /demander un nouveau lien/i })).toHaveAttribute('href', '/mot-de-passe-oublie') + }) + + it('envoie un mot de passe conforme', async () => { + resetPasswordApi.mockResolvedValue({ message: 'ok' }) + renderWithProviders(<DefinirMotDePasse />, { route: '/definir-mot-de-passe/abc' }) + + await remplir('MonMotDePasse1!') + + expect(resetPasswordApi).toHaveBeenCalledWith( + expect.objectContaining({ password: 'MonMotDePasse1!', confirmPassword: 'MonMotDePasse1!' }) + ) + }) +}) diff --git a/frontend/src/test/fonctionnel/EmployeeDeliveries.test.jsx b/frontend/src/test/fonctionnel/EmployeeDeliveries.test.jsx new file mode 100644 index 0000000..4e46bb7 --- /dev/null +++ b/frontend/src/test/fonctionnel/EmployeeDeliveries.test.jsx @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import EmployeeDeliveries from '../../components/admin/EmployeeDeliveries' +import { getEmployeesApi, getEmployeeDeliveriesApi, getDeliveryByTicketCodeApi } from '../../services/admin' + +vi.mock('../../services/admin', () => ({ + getEmployeesApi: vi.fn(), + getEmployeeDeliveriesApi: vi.fn(), + getDeliveryByTicketCodeApi: vi.fn(), +})) + +const MARC = { id: 10, firstname: 'Marc', lastname: 'Durand', email: 'marc@example.com', createdAt: '2026-09-01T00:00:00Z', mustChangePassword: false, deliveredCount: 1 } +const SOPHIE = { id: 11, firstname: 'Sophie', lastname: 'Bernard', email: 'sophie@example.com', createdAt: '2026-09-02T00:00:00Z', mustChangePassword: true, deliveredCount: 0 } + +function renderComponent() { + return render(<EmployeeDeliveries refreshKey={0} onAuthError={() => false} />) +} + +describe('EmployeeDeliveries', () => { + beforeEach(() => { + vi.clearAllMocks() + getEmployeesApi.mockResolvedValue({ employees: [MARC, SOPHIE] }) + }) + + it('liste les employés et filtre par nom', async () => { + renderComponent() + + expect(await screen.findByRole('button', { name: 'Marc Durand' })).toBeInTheDocument() + expect(screen.getByText('Première connexion en attente')).toBeInTheDocument() + + await userEvent.type(screen.getByLabelText(/rechercher un employé/i), 'sophie') + + expect(screen.queryByRole('button', { name: 'Marc Durand' })).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Sophie Bernard' })).toBeInTheDocument() + }) + + it("affiche les lots remis quand on clique sur le nom d'un employé", async () => { + getEmployeeDeliveriesApi.mockResolvedValue({ + employee: MARC, + deliveries: [{ + claimId: 1, + ticketCode: 'A1B2C3D4E5', + lot: { name: 'Infuseur à thé', value: 39 }, + client: { id: 5, firstname: 'Chloé', lastname: 'Martin', email: 'chloe@example.com' }, + deliveredAt: '2026-09-10T14:30:00Z', + }], + }) + renderComponent() + + await userEvent.click(await screen.findByRole('button', { name: 'Marc Durand' })) + + expect(getEmployeeDeliveriesApi).toHaveBeenCalledWith(10) + expect(await screen.findByText('A1B2C3D4E5')).toBeInTheDocument() + expect(screen.getByText(/lots remis par marc durand/i)).toBeInTheDocument() + expect(screen.getByText('Infuseur à thé')).toBeInTheDocument() + }) + + it("retrouve l'employé et la date de remise à partir du numéro de ticket", async () => { + getDeliveryByTicketCodeApi.mockResolvedValue({ + ticketCode: 'A1B2C3D4E5', + lot: { name: 'Infuseur à thé', value: 39 }, + status: 'DELIVERED', + playedAt: '2026-09-01T10:00:00Z', + client: { id: 5, firstname: 'Chloé', lastname: 'Martin', email: 'chloe@example.com' }, + deliveredBy: { id: 10, firstname: 'Marc', lastname: 'Durand', email: 'marc@example.com' }, + deliveredAt: '2026-09-10T14:30:00Z', + }) + getEmployeeDeliveriesApi.mockResolvedValue({ employee: MARC, deliveries: [] }) + renderComponent() + + await userEvent.type(screen.getByLabelText(/numéro du ticket/i), 'a1b2c3d4e5') + await userEvent.click(screen.getByRole('button', { name: /^rechercher$/i })) + + const resultat = await screen.findByTestId('ticket-result') + expect(getDeliveryByTicketCodeApi).toHaveBeenCalledWith('a1b2c3d4e5') + expect(within(resultat).getByText('Lot remis')).toBeInTheDocument() + expect(within(resultat).getByText(/remis par/i)).toHaveTextContent(new Date('2026-09-10T14:30:00Z').toLocaleString('fr-FR', { + day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', + })) + + // Le nom de l'employé mène à la liste de ses remises. + await userEvent.click(within(resultat).getByRole('button', { name: 'Marc Durand' })) + expect(getEmployeeDeliveriesApi).toHaveBeenCalledWith(10) + expect(await screen.findByText(/n'a encore remis aucun lot/i)).toBeInTheDocument() + }) + + it("affiche l'erreur quand le ticket n'existe pas", async () => { + getDeliveryByTicketCodeApi.mockRejectedValue(new Error('Aucun ticket ne correspond à ce numéro.')) + renderComponent() + + await userEvent.type(screen.getByLabelText(/numéro du ticket/i), 'INCONNU') + await userEvent.click(screen.getByRole('button', { name: /^rechercher$/i })) + + expect(await screen.findByText('Aucun ticket ne correspond à ce numéro.')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/fonctionnel/Inscription.test.jsx b/frontend/src/test/fonctionnel/Inscription.test.jsx index e774776..098b433 100644 --- a/frontend/src/test/fonctionnel/Inscription.test.jsx +++ b/frontend/src/test/fonctionnel/Inscription.test.jsx @@ -10,13 +10,21 @@ vi.mock('../../services/auth', () => ({ verifyCodeApi: vi.fn(), })) -// Remplit et soumet le formulaire d'inscription (étape 1). -async function remplirEtSoumettre() { +// Remplit les champs obligatoires (la date de naissance l'est depuis la règle +// des 18 ans), sans soumettre. +async function remplirChampsObligatoires({ password = 'MotDePasse123!', confirmPassword = password } = {}) { await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com') await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont') await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean') - await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!') + await userEvent.type(screen.getByLabelText(/date de naissance/i), '1990-05-14') + await userEvent.type(screen.getByLabelText(/^mot de passe/i), password) + await userEvent.type(screen.getByLabelText(/confirmer le mot de passe/i), confirmPassword) await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i })) +} + +// Remplit et soumet le formulaire d'inscription (étape 1). +async function remplirEtSoumettre(options) { + await remplirChampsObligatoires(options) await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i })) } @@ -41,6 +49,50 @@ describe('Inscription', () => { expect(screen.getByLabelText(/^nom/i)).toBeInTheDocument() expect(screen.getByLabelText(/prénom/i)).toBeInTheDocument() expect(screen.getByLabelText(/^mot de passe/i)).toBeInTheDocument() + expect(screen.getByLabelText(/confirmer le mot de passe/i)).toBeInTheDocument() + }) + + it('coche les règles du mot de passe au fil de la saisie', async () => { + const { container } = renderWithProviders(<Inscription />, { route: '/inscription' }) + const regle = (id) => container.querySelector(`[data-rule="${id}"]`) + + expect(regle('length')).toHaveAttribute('data-valid', 'false') + expect(regle('special')).toHaveAttribute('data-valid', 'false') + + await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'abcdefgh') + expect(regle('length')).toHaveAttribute('data-valid', 'true') + expect(regle('special')).toHaveAttribute('data-valid', 'false') + + await userEvent.type(screen.getByLabelText(/^mot de passe/i), '!') + expect(regle('special')).toHaveAttribute('data-valid', 'true') + }) + + it("refuse un mot de passe sans caractère spécial sans appeler l'API", async () => { + renderWithProviders(<Inscription />, { route: '/inscription' }) + await remplirEtSoumettre({ password: 'MotDePasse123' }) + + expect(screen.getByText(/au moins 8 caractères, dont un caractère spécial/i)).toBeInTheDocument() + expect(registerApi).not.toHaveBeenCalled() + }) + + it("refuse un mot de passe trop court sans appeler l'API", async () => { + renderWithProviders(<Inscription />, { route: '/inscription' }) + await remplirEtSoumettre({ password: 'Ab1!xyz' }) + + expect(screen.getByText(/au moins 8 caractères, dont un caractère spécial/i)).toBeInTheDocument() + expect(registerApi).not.toHaveBeenCalled() + }) + + it("signale une confirmation différente et bloque l'inscription", async () => { + renderWithProviders(<Inscription />, { route: '/inscription' }) + await remplirChampsObligatoires({ password: 'MotDePasse123!', confirmPassword: 'MotDePasse124!' }) + + expect(screen.getByLabelText(/confirmer le mot de passe/i)).toHaveAttribute('aria-invalid', 'true') + + await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i })) + + expect(screen.getAllByText(/ne correspondent pas/i).length).toBeGreaterThan(0) + expect(registerApi).not.toHaveBeenCalled() }) it("désactive le bouton tant que les conditions ne sont pas acceptées", async () => { @@ -64,9 +116,11 @@ describe('Inscription', () => { firstname: 'Jean', lastname: 'Dupont', password: 'MotDePasse123!', - birthdate: '', + confirmPassword: 'MotDePasse123!', + birthdate: '1990-05-14', gender: '', marketingOptIn: false, + captchaToken: '', }) expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument() expect(screen.getByText('jean@example.com')).toBeInTheDocument() @@ -83,6 +137,7 @@ describe('Inscription', () => { await userEvent.type(screen.getByLabelText(/date de naissance/i), '1990-05-14') await userEvent.selectOptions(screen.getByLabelText(/genre/i), 'Femme') await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!') + await userEvent.type(screen.getByLabelText(/confirmer le mot de passe/i), 'MotDePasse123!') await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i })) await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i })) @@ -99,13 +154,8 @@ describe('Inscription', () => { const marketing = screen.getByRole('checkbox', { name: /offres et actualités/i }) expect(marketing).not.toBeChecked() - await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com') - await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont') - await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean') - await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!') await userEvent.click(marketing) - await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i })) - await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i })) + await remplirEtSoumettre() expect(registerApi).toHaveBeenCalledWith( expect.objectContaining({ marketingOptIn: true }) diff --git a/frontend/src/test/fonctionnel/MotDePasseOublie.test.jsx b/frontend/src/test/fonctionnel/MotDePasseOublie.test.jsx new file mode 100644 index 0000000..c90149f --- /dev/null +++ b/frontend/src/test/fonctionnel/MotDePasseOublie.test.jsx @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import MotDePasseOublie from '../../pages/MotDePasseOublie' +import Connexion from '../../pages/Connexion' +import { forgotPasswordApi } from '../../services/auth' +import { renderWithProviders } from '../renderWithProviders' + +vi.mock('../../services/auth', () => ({ + forgotPasswordApi: vi.fn(), + loginApi: vi.fn(), +})) + +describe('MotDePasseOublie', () => { + beforeEach(() => vi.clearAllMocks()) + + it("envoie la demande et affiche la confirmation neutre", async () => { + forgotPasswordApi.mockResolvedValue({ message: 'Si un compte existe avec cet email, un lien de réinitialisation vient de lui être envoyé.' }) + renderWithProviders(<MotDePasseOublie />, { route: '/mot-de-passe-oublie' }) + + await userEvent.type(screen.getByLabelText(/adresse email/i), 'lea@example.com') + await userEvent.click(screen.getByRole('button', { name: /recevoir le lien/i })) + + expect(forgotPasswordApi).toHaveBeenCalledWith({ email: 'lea@example.com', captchaToken: '' }) + expect(await screen.findByRole('status')).toHaveTextContent(/si un compte existe/i) + expect(screen.getByRole('link', { name: /retour à la connexion/i })).toHaveAttribute('href', '/connexion') + }) + + it("affiche l'erreur renvoyée par l'API", async () => { + forgotPasswordApi.mockRejectedValue(new Error('Trop de tentatives, réessayez dans quelques minutes.')) + renderWithProviders(<MotDePasseOublie />, { route: '/mot-de-passe-oublie' }) + + await userEvent.type(screen.getByLabelText(/adresse email/i), 'lea@example.com') + await userEvent.click(screen.getByRole('button', { name: /recevoir le lien/i })) + + expect(await screen.findByRole('alert')).toHaveTextContent(/trop de tentatives/i) + }) + + it("le lien de la page de connexion y mène en reprenant l'email saisi", async () => { + renderWithProviders(<Connexion />, { + route: '/connexion', + routes: { '/mot-de-passe-oublie': <MotDePasseOublie /> }, + }) + + await userEvent.type(screen.getByLabelText(/adresse email/i), 'lea@example.com') + await userEvent.click(screen.getByRole('link', { name: /mot de passe oublié/i })) + + expect(await screen.findByRole('heading', { name: /mot de passe oublié/i })).toBeInTheDocument() + expect(screen.getByLabelText(/adresse email/i)).toHaveValue('lea@example.com') + }) +}) diff --git a/frontend/src/test/fonctionnel/NouveauMotDePasse.test.jsx b/frontend/src/test/fonctionnel/NouveauMotDePasse.test.jsx new file mode 100644 index 0000000..37329eb --- /dev/null +++ b/frontend/src/test/fonctionnel/NouveauMotDePasse.test.jsx @@ -0,0 +1,74 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import NouveauMotDePasse from '../../pages/NouveauMotDePasse' +import { setInitialPasswordApi } from '../../services/auth' +import { renderWithProviders } from '../renderWithProviders' + +vi.mock('../../services/auth', () => ({ + setInitialPasswordApi: vi.fn(), +})) + +const EMPLOYE = { id: 3, firstname: 'Marc', email: 'marc@example.com', role: 'EMPLOYEE', mustChangePassword: true } + +async function remplir(password, confirmPassword) { + await userEvent.type(screen.getByLabelText(/^nouveau mot de passe/i), password) + await userEvent.type(screen.getByLabelText(/^confirmer/i), confirmPassword) + await userEvent.click(screen.getByRole('button', { name: /enregistrer/i })) +} + +describe('NouveauMotDePasse', () => { + beforeEach(() => vi.clearAllMocks()) + + it("refuse deux mots de passe différents sans appeler l'API", async () => { + renderWithProviders(<NouveauMotDePasse />, { route: '/nouveau-mot-de-passe', user: EMPLOYE }) + + await remplir('NouveauMdp123!', 'AutreMdp456!') + + expect(screen.getByRole('alert')).toHaveTextContent(/ne correspondent pas/i) + expect(setInitialPasswordApi).not.toHaveBeenCalled() + }) + + it("enregistre le mot de passe puis ouvre l'espace employé", async () => { + setInitialPasswordApi.mockResolvedValue({ message: 'ok' }) + renderWithProviders(<NouveauMotDePasse />, { + route: '/nouveau-mot-de-passe', + user: EMPLOYE, + routes: { '/employe': <p>Page Employé</p> }, + }) + + await remplir('NouveauMdp123!', 'NouveauMdp123!') + + expect(await screen.findByText('Page Employé')).toBeInTheDocument() + expect(setInitialPasswordApi).toHaveBeenCalledWith({ password: 'NouveauMdp123!', confirmPassword: 'NouveauMdp123!' }) + expect(JSON.parse(localStorage.getItem('user')).mustChangePassword).toBe(false) + }) + + it("affiche l'erreur renvoyée par l'API", async () => { + setInitialPasswordApi.mockRejectedValue(new Error('Choisissez un mot de passe différent du mot de passe provisoire.')) + renderWithProviders(<NouveauMotDePasse />, { route: '/nouveau-mot-de-passe', user: EMPLOYE }) + + await remplir('Provisoire12!', 'Provisoire12!') + + expect(await screen.findByText(/différent du mot de passe provisoire/i)).toBeInTheDocument() + }) + + it("refuse un mot de passe sans caractère spécial sans appeler l'API", async () => { + renderWithProviders(<NouveauMotDePasse />, { route: '/nouveau-mot-de-passe', user: EMPLOYE }) + + await remplir('NouveauMdp123', 'NouveauMdp123') + + expect(screen.getByRole('alert')).toHaveTextContent(/caractère spécial/i) + expect(setInitialPasswordApi).not.toHaveBeenCalled() + }) + + it("renvoie vers son espace un utilisateur qui n'a pas de mot de passe provisoire", () => { + renderWithProviders(<NouveauMotDePasse />, { + route: '/nouveau-mot-de-passe', + user: { ...EMPLOYE, mustChangePassword: false }, + routes: { '/employe': <p>Page Employé</p> }, + }) + + expect(screen.getByText('Page Employé')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/test/fonctionnel/ProtectedRoute.test.jsx b/frontend/src/test/fonctionnel/ProtectedRoute.test.jsx index b33fc34..c13b413 100644 --- a/frontend/src/test/fonctionnel/ProtectedRoute.test.jsx +++ b/frontend/src/test/fonctionnel/ProtectedRoute.test.jsx @@ -77,4 +77,20 @@ describe('ProtectedRoute', () => { expect(screen.getByText('Page Employé')).toBeInTheDocument() }) + + it('impose le choix du mot de passe tant que le provisoire n\'est pas remplacé', () => { + renderWithProviders( + <ProtectedRoute roles={['EMPLOYEE']}> + <p>Espace employé</p> + </ProtectedRoute>, + { + route: '/employe', + user: { id: 3, email: 'employe@example.com', role: 'EMPLOYEE', mustChangePassword: true }, + routes: { '/nouveau-mot-de-passe': <p>Page Nouveau mot de passe</p> }, + } + ) + + expect(screen.getByText('Page Nouveau mot de passe')).toBeInTheDocument() + expect(screen.queryByText('Espace employé')).not.toBeInTheDocument() + }) }) diff --git a/frontend/src/test/unit/gameDates.test.js b/frontend/src/test/unit/gameDates.test.js new file mode 100644 index 0000000..7b3ff1e --- /dev/null +++ b/frontend/src/test/unit/gameDates.test.js @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { toGameDates, gameDatesFromStart, formatDay, formatRange } from '../../utils/gameDates' + +describe('gameDates', () => { + it('retombe sur le 2 → 31 octobre 2026 quand le jeu n\'est pas configuré', () => { + for (const gameWindow of [null, { configured: false, startDate: null }]) { + const { start, lastPlayDay, lastClaimDay } = toGameDates(gameWindow) + expect(formatRange(start, lastPlayDay, { padDay: true })).toBe('02 au 31 octobre 2026') + expect(formatDay(lastClaimDay)).toBe('30 novembre 2026') + } + }) + + it("utilise les échéances renvoyées par l'API", () => { + const dates = toGameDates({ + configured: true, + startDate: '2026-11-14T23:00:00.000Z', // 15 novembre 00:00 à Paris + participationDeadline: '2026-12-14T23:00:00.000Z', + claimDeadline: '2027-01-13T23:00:00.000Z', + }) + + expect(formatRange(dates.start, dates.lastPlayDay)).toBe('15 novembre au 14 décembre 2026') + expect(formatDay(dates.lastClaimDay)).toBe('13 janvier 2027') + }) + + it("calcule l'aperçu admin à partir de la seule date de début, en heure de Paris", () => { + const dates = gameDatesFromStart(new Date('2026-10-02T00:00:00+02:00')) + expect(formatRange(dates.start, dates.lastPlayDay)).toBe('2 au 31 octobre 2026') + expect(formatDay(dates.lastClaimDay)).toBe('30 novembre 2026') + }) + + it('formate une période à cheval sur deux années', () => { + // Début à 10 h : l'échéance tombe le 19 janvier à 10 h, dernier jour jouable. + const dates = gameDatesFromStart(new Date('2026-12-20T10:00:00+01:00')) + expect(formatRange(dates.start, dates.lastPlayDay)).toBe('20 décembre 2026 au 19 janvier 2027') + expect(formatRange(dates.start, dates.lastPlayDay, { withYear: false })).toBe('20 décembre au 19 janvier') + }) +}) diff --git a/frontend/src/utils/gameDates.js b/frontend/src/utils/gameDates.js new file mode 100644 index 0000000..ef4c008 --- /dev/null +++ b/frontend/src/utils/gameDates.js @@ -0,0 +1,80 @@ +// Dates du jeu-concours affichées au public. Elles viennent de l'API (date de +// début fixée par l'admin) ; en attendant la réponse, ou si le jeu n'est pas +// configuré, on affiche les dates prévues par le règlement : 2 → 31 octobre 2026. + +const TIME_ZONE = 'Europe/Paris' +const DAY_MS = 24 * 60 * 60 * 1000 + +// Mêmes durées que backend/src/utils/gameWindow.js. +const GAME_DURATION_DAYS = 30 +const CLAIM_EXTENSION_DAYS = 30 + +export const DEFAULT_START_DATE = '2026-10-02T00:00:00+02:00' + +function windowFromStart(startDate) { + const start = new Date(startDate) + return { + start, + participationDeadline: new Date(start.getTime() + GAME_DURATION_DAYS * DAY_MS), + claimDeadline: new Date(start.getTime() + (GAME_DURATION_DAYS + CLAIM_EXTENSION_DAYS) * DAY_MS), + } +} + +// Les échéances sont des instants exclusifs (début + 30 j) : le dernier jour +// affiché est celui qui précède immédiatement l'échéance. +function lastDayBefore(deadline) { + return new Date(deadline.getTime() - 1) +} + +export function toGameDates(gameWindow) { + const source = gameWindow?.configured && gameWindow.startDate + ? { + start: new Date(gameWindow.startDate), + participationDeadline: new Date(gameWindow.participationDeadline), + claimDeadline: new Date(gameWindow.claimDeadline), + } + : windowFromStart(DEFAULT_START_DATE) + + return { + start: source.start, + lastPlayDay: lastDayBefore(source.participationDeadline), + lastClaimDay: lastDayBefore(source.claimDeadline), + } +} + +// Même calcul à partir d'une date de début seule (aperçu dans l'admin). +export function gameDatesFromStart(startDate) { + return toGameDates({ configured: true, startDate, ...windowFromStart(startDate) }) +} + +function parts(date) { + const values = {} + new Intl.DateTimeFormat('fr-FR', { timeZone: TIME_ZONE, day: 'numeric', month: 'long', year: 'numeric' }) + .formatToParts(date) + .forEach(({ type, value }) => { values[type] = value }) + return values +} + +// « 30 novembre 2026 » +export function formatDay(date) { + const { day, month, year } = parts(date) + return `${day} ${month} ${year}` +} + +// « 2 au 31 octobre 2026 », « 15 octobre au 13 novembre 2026 », +// « 20 décembre 2026 au 18 janvier 2027 ». Sans « du » : l'appelant choisit +// la majuscule. `padDay` écrit « 02 », `withYear: false` omet l'année. +export function formatRange(start, end, { padDay = false, withYear = true } = {}) { + const a = parts(start) + const b = parts(end) + const startDay = padDay ? a.day.padStart(2, '0') : a.day + const year = withYear ? ` ${b.year}` : '' + + if (a.year !== b.year) { + return `${startDay} ${a.month}${withYear ? ` ${a.year}` : ''} au ${b.day} ${b.month}${year}` + } + if (a.month !== b.month) { + return `${startDay} ${a.month} au ${b.day} ${b.month}${year}` + } + return `${startDay} au ${b.day} ${b.month}${year}` +} diff --git a/frontend/src/utils/passwordRules.js b/frontend/src/utils/passwordRules.js new file mode 100644 index 0000000..ad33a51 --- /dev/null +++ b/frontend/src/utils/passwordRules.js @@ -0,0 +1,14 @@ +// Règle de mot de passe, identique au backend +// (backend/src/validations/password.validation.js) : plus de 7 caractères et +// au moins un caractère spécial (ni lettre, accentuée comprise, ni chiffre, +// ni espace). +export const PASSWORD_RULES = [ + { id: 'length', label: '8 caractères minimum', test: (password) => password.length >= 8 }, + { id: 'special', label: 'Au moins un caractère spécial (!, @, #, ?, …)', test: (password) => /[^\p{L}\p{N}\s]/u.test(password) }, +] + +export function isPasswordValid(password) { + return PASSWORD_RULES.every((rule) => rule.test(password)) +} + +export const PASSWORD_RULES_ERROR = 'Le mot de passe doit contenir au moins 8 caractères, dont un caractère spécial.'