From 21648ab171417d46229605e005e428ce302a7612 Mon Sep 17 00:00:00 2001 From: Alexis SANOGO Date: Mon, 17 Aug 2026 21:50:21 +0200 Subject: [PATCH 01/13] 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/13] 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 6096da5bb8624b39873a4c29ccb051423d6fd510 Mon Sep 17 00:00:00 2001 From: Anilzer Date: Fri, 21 Aug 2026 02:32:55 +0200 Subject: [PATCH 03/13] add readMe Files --- README.md | 781 +++++++++++++++++++++++++++++++++++++++++++++ backend/README.md | 559 ++++++++++++++------------------ frontend/README.md | 277 +++++++++++++++- 3 files changed, 1287 insertions(+), 330 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..a07f9a2 --- /dev/null +++ b/README.md @@ -0,0 +1,781 @@ +# Thé Tip Top – Plateforme du jeu-concours + +## Présentation + +**Thé Tip Top** est une application web complète développée dans le cadre d’un projet de certification **Expert en Stratégie et Transformation Digitale**. + +Le projet simule le système d’information d’un jeu-concours organisé à l’occasion de l’ouverture de la 10ᵉ boutique Thé Tip Top à Nice. + +La plateforme permet aux clients de participer avec un code unique présent sur leur ticket, de consulter leurs gains et de les récupérer en boutique. Elle fournit également des interfaces dédiées aux employés et aux administrateurs. + +Au-delà de l’application, le projet met en œuvre une chaîne complète de **CI/CD**, plusieurs environnements isolés, un registre Docker privé, un reverse proxy HTTPS et une stack de supervision. + +> **Projet étudiant — client fictif Thé Tip Top.** + +--- + +## Fonctionnalités principales + +### Espace client + +- création de compte ; +- vérification de l’adresse email ; +- connexion email / mot de passe ; +- connexion avec Google OAuth 2.0 ; +- gestion du profil ; +- saisie d’un code ticket unique ; +- participation au jeu-concours ; +- consultation des gains. + +### Espace employé + +- authentification avec rôle `EMPLOYEE` ; +- recherche d’un gain ; +- contrôle des informations associées au ticket ; +- validation de la remise d’un lot. + +### Espace administrateur + +- authentification avec rôle `ADMIN` ; +- tableau de bord et statistiques ; +- consultation des participants ; +- gestion des données nécessaires à l’administration ; +- création de comptes employés ; +- configuration du lancement du jeu-concours ; +- tirage au sort final ; +- consultation du grand gagnant. + +### Jeu-concours + +Le système gère plusieurs phases : + +```text +NOT_CONFIGURED + ↓ +SCHEDULED + ↓ +OPEN + ↓ +CLAIM_ONLY + ↓ +CLOSED +``` + +Parmi les principales règles métier : + +- un ticket ne peut être utilisé qu’une seule fois ; +- les tickets du jeu sont gagnants ; +- les gains sont associés aux tickets ; +- seuls les clients participent au jeu ; +- plusieurs participations d’un même client n’augmentent pas ses chances au tirage final ; +- un seul grand gagnant peut être enregistré ; +- le tirage final respecte les contraintes temporelles définies dans l’application. + +--- + +# Architecture générale + +```text + UTILISATEURS + │ + HTTPS + │ + ▼ + ┌─────────┐ + │ Traefik │ + └────┬────┘ + │ + ┌────────────┴────────────┐ + │ │ + ▼ ▼ + Frontend React Backend API + Vite + Nginx Node.js + Express + │ │ + │ ▼ + │ PostgreSQL + │ + Prisma + │ + └────────── API REST ─────┘ +``` + +--- + +# Stack technique + +## Frontend + +- React +- Vite +- React Router +- Tailwind CSS +- Vitest +- Testing Library +- ESLint +- Nginx + +## Backend + +- Node.js 22 +- Express.js +- Prisma ORM 6 +- PostgreSQL 16 +- JWT +- Passport.js +- Google OAuth 2.0 +- Bcrypt +- Zod +- Nodemailer +- Helmet +- express-rate-limit +- Swagger / OpenAPI +- Jest +- Supertest + +## DevOps + +- Git +- Gitea +- Jenkins +- Docker +- Docker Compose +- Docker Registry privé +- Traefik +- HTTPS +- Let's Encrypt + +## Monitoring + +- Prometheus +- Grafana +- cAdvisor +- Node Exporter + +--- + +# Structure du dépôt + +```text +THE-TIP-TOP/ +│ +├── backend/ +│ ├── prisma/ +│ │ ├── migrations/ +│ │ ├── schema.prisma +│ │ └── seed.js +│ ├── src/ +│ ├── tests/ +│ ├── Dockerfile +│ ├── package.json +│ └── README.md +│ +├── frontend/ +│ ├── src/ +│ │ ├── components/ +│ │ ├── context/ +│ │ ├── pages/ +│ │ ├── services/ +│ │ └── test/ +│ ├── Dockerfile +│ ├── nginx.conf +│ ├── package.json +│ └── README.md +│ +├── deploy/ +│ ├── dev/ +│ │ └── docker-compose.yml +│ ├── preprod/ +│ │ └── docker-compose.yml +│ └── prod/ +│ └── docker-compose.yml +│ +├── Jenkinsfile +└── README.md +``` + +Pour les informations spécifiques à chaque application : + +- [`backend/README.md`](backend/README.md) +- [`frontend/README.md`](frontend/README.md) + +--- + +# Environnements + +Le projet utilise trois environnements distincts. + +| Environnement | Branche | Frontend | API | +|---|---|---|---| +| DEV | `develop` | `https://dev.dsp5-archi-o24a-4-5-g4.fr` | `https://api-dev.dsp5-archi-o24a-4-5-g4.fr` | +| PREPROD | `release-*` | `https://preprod.dsp5-archi-o24a-4-5-g4.fr` | `https://api-preprod.dsp5-archi-o24a-4-5-g4.fr` | +| PROD | `main` | `https://dsp5-archi-o24a-4-5-g4.fr` | `https://api.dsp5-archi-o24a-4-5-g4.fr` | + +Les bases PostgreSQL et conteneurs applicatifs sont séparés entre les environnements. + +--- + +# Stratégie Git + +Le workflow de branches est organisé autour de plusieurs niveaux de validation. + +```text +feature/* + │ + │ Pull Request / Merge + ▼ +develop + │ + │ CI + déploiement DEV + ▼ +release-* + │ + │ CI + déploiement PREPROD + ▼ +main + │ + │ validation manuelle Jenkins + ▼ +PRODUCTION +``` + +### `feature/*` + +Branches utilisées pour le développement des fonctionnalités. + +### `develop` + +Branche d’intégration. Un changement validé sur cette branche est déployé automatiquement en DEV. + +### `release-*` + +Branches de préparation d’une version. Elles sont déployées en PREPROD pour validation avant production. + +### `main` + +Branche stable de production. Le déploiement PROD nécessite une validation manuelle dans Jenkins. + +--- + +# Pipeline CI/CD + +Le fichier `Jenkinsfile` placé à la racine du dépôt définit la chaîne d’intégration et de déploiement. + +```text +Push Gitea + │ + ▼ +Jenkins + │ + ├── Checkout + │ + ├── Vérification workspace + │ + ├── PostgreSQL CI temporaire + │ + ├── Prisma migrations + │ + ├── Prisma seed + │ + ├── Tests backend Jest + │ + ├── Publication JUnit + │ + ├── Frontend ESLint + │ + ├── Frontend Vite build + │ + ├── Docker build + │ + ├── Push Registry privé + │ + └── Docker Compose deploy + │ + ▼ +DEV / PREPROD / PROD +``` + +## Comportement selon la branche + +### Feature + +```text +Push + ↓ +Tests + ↓ +Lint + ↓ +Build +``` + +Pas de déploiement automatique. + +### Develop + +```text +Push develop + ↓ +CI + ↓ +Images Docker + ↓ +Registry + ↓ +Déploiement DEV +``` + +### Release + +```text +Push release-* + ↓ +CI + ↓ +Images Docker + ↓ +Registry + ↓ +Déploiement PREPROD +``` + +### Main + +```text +Push main + ↓ +CI + ↓ +Images Docker + ↓ +Registry + ↓ +Validation Jenkins + ↓ +Déploiement PROD +``` + +Cette validation constitue une barrière supplémentaire avant la mise en production. + +--- + +# Tests automatisés + +## Backend + +Les tests utilisent **Jest** et **Supertest**. + +Ils couvrent notamment : + +- inscription ; +- connexion ; +- parcours E2E ; +- autorisations ; +- participation ; +- phases du jeu ; +- tirage au sort ; +- notifications liées aux gains ; +- notification du grand gagnant ; +- authentification Google ; +- gestion du mot de passe ; +- comportements liés aux emails. + +Commande : + +```bash +cd backend +npm test +``` + +Dans Jenkins, PostgreSQL est lancé temporairement pour les tests. Les migrations et données initiales sont appliquées avant l’exécution des suites. + +## Frontend + +Les tests utilisent **Vitest** et **Testing Library**. + +Ils couvrent notamment : + +- connexion ; +- inscription ; +- formulaire d’inscription ; +- saisie du code unique ; +- routes protégées ; +- contexte d’authentification ; +- gestion des erreurs d’authentification ; +- services ; +- bannière de cookies. + +Commandes : + +```bash +cd frontend +npm run lint +npm test -- --run +npm run build +``` + +--- + +# Authentification et sécurité + +Le projet met en œuvre plusieurs mécanismes de sécurité. + +## Authentification + +- JWT ; +- email / mot de passe ; +- Google OAuth 2.0 ; +- vérification email. + +## Protection des mots de passe + +Les mots de passe sont hachés avec **Bcrypt** avant stockage. + +## Validation + +Les données reçues par l’API sont validées avec **Zod**. + +## Contrôle d’accès + +Des middlewares backend contrôlent : + +- l’authentification ; +- le rôle ; +- l’accès aux routes protégées. + +## Sécurité HTTP + +Le backend utilise notamment : + +- Helmet ; +- rate limiting ; +- validation des entrées. + +## HTTPS + +Traefik assure le routage HTTP/HTTPS vers les différents services. + +--- + +# Gestion des secrets + +Les secrets ne doivent jamais être stockés directement dans le dépôt Git. + +Sont notamment concernés : + +```text +JWT_SECRET +DATABASE_URL / mots de passe PostgreSQL +MAIL_USER +MAIL_PASS +GOOGLE_CLIENT_ID +GOOGLE_CLIENT_SECRET +FACEBOOK_APP_ID +FACEBOOK_APP_SECRET +identifiants du Docker Registry +``` + +Les valeurs sensibles de déploiement sont externalisées sur le serveur ou injectées par Jenkins. + +> Les fichiers contenant des secrets réels ne doivent jamais être ajoutés au dépôt. + +--- + +# Docker + +Le frontend et le backend disposent chacun de leur propre image Docker. + +## Backend + +```text +Node.js 22 + ↓ +npm ci + ↓ +Prisma generate + ↓ +Application Express +``` + +## Frontend + +Build multi-stage : + +```text +Node.js 22 + ↓ +npm ci + ↓ +Vite build + ↓ +Nginx +``` + +Les images sont versionnées et publiées dans le Registry privé par Jenkins. + +--- + +# Docker Compose + +Chaque environnement possède son propre fichier Compose : + +```text +deploy/dev/docker-compose.yml +deploy/preprod/docker-compose.yml +deploy/prod/docker-compose.yml +``` + +Chaque stack contient notamment : + +```text +Frontend +Backend +PostgreSQL +``` + +Les stacks sont isolées avec leurs propres conteneurs, réseaux, données et paramètres d’environnement. + +--- + +# Reverse proxy et HTTPS + +**Traefik** constitue le point d’entrée HTTP/HTTPS de l’infrastructure. + +Il permet notamment : + +- le routage par nom de domaine ; +- l’exposition des frontends ; +- l’exposition des APIs ; +- la terminaison TLS ; +- la gestion des certificats HTTPS. + +Seuls les ports nécessaires sont exposés publiquement. + +--- + +# Monitoring + +La plateforme dispose d’une stack de supervision. + +```text +Node Exporter ──┐ + │ +cAdvisor ───────┼──► Prometheus ───► Grafana + │ +Services ───────┘ +``` + +### Prometheus + +Collecte les métriques. + +### Grafana + +Permet de construire et consulter les tableaux de bord. + +### cAdvisor + +Expose les métriques relatives aux conteneurs Docker. + +### Node Exporter + +Expose les métriques système du serveur. + +--- + +# Documentation API + +Le backend expose une documentation Swagger / OpenAPI. + +En environnement local : + +```text +http://localhost:5000/api-docs +``` + +Elle permet de : + +- consulter les routes ; +- consulter les schémas ; +- examiner les paramètres ; +- tester les endpoints ; +- utiliser l’authentification prévue par l’API. + +--- + +# Installation locale + +## 1. Cloner le dépôt + +```bash +git clone +cd THE-TIP-TOP +``` + +## 2. Backend + +```bash +cd backend +npm install +``` + +Configurer les variables d’environnement locales puis : + +```bash +npx prisma generate +npx prisma migrate dev +npx prisma db seed +npm run dev +``` + +## 3. Frontend + +Dans un second terminal : + +```bash +cd frontend +npm install +npm run dev +``` + +Le frontend local est généralement accessible sur : + +```text +http://localhost:5173 +``` + +et l’API sur : + +```text +http://localhost:5000 +``` + +Consulter les README des sous-projets pour les détails de configuration. + +--- + +# Base de données + +Le projet utilise **PostgreSQL 16** avec **Prisma ORM 6**. + +Commandes Prisma utiles : + +```bash +npx prisma generate +npx prisma migrate dev +npx prisma migrate deploy +npx prisma db seed +npx prisma studio +``` + +En environnement déployé, les migrations sont appliquées avec `prisma migrate deploy`. + +--- + +# Qualité logicielle + +Le projet applique plusieurs niveaux de contrôle : + +```text +Code + ↓ +ESLint + ↓ +Tests unitaires / fonctionnels + ↓ +Tests backend / E2E + ↓ +Build + ↓ +Docker Build + ↓ +Déploiement +``` + +Un échec dans une étape critique provoque l’échec du pipeline et empêche la promotion normale de la version. + +--- + +# Infrastructure DevOps + +Vue simplifiée : + +```text + ┌───────────────┐ + │ Gitea │ + └───────┬───────┘ + │ Webhook + ▼ + ┌───────────────┐ + │ Jenkins │ + └───────┬───────┘ + │ + Tests / Build / Docker + │ + ▼ + ┌───────────────┐ + │Docker Registry│ + └───────┬───────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + DEV PREPROD PROD + │ │ │ + └───────────────┼───────────────┘ + ▼ + Traefik + │ + HTTPS +``` + +--- + +# État du projet + +Le projet dispose notamment de : + +- frontend React opérationnel ; +- backend Express opérationnel ; +- PostgreSQL et Prisma ; +- authentification JWT ; +- Google OAuth 2.0 ; +- vérification email ; +- gestion CLIENT / EMPLOYEE / ADMIN ; +- jeu-concours et gestion des gains ; +- tirage final ; +- tests automatisés ; +- documentation Swagger ; +- images Docker ; +- Registry privé ; +- pipeline Jenkins multibranch ; +- environnements DEV / PREPROD / PROD ; +- déploiements Docker Compose ; +- reverse proxy Traefik ; +- HTTPS ; +- monitoring Prometheus / Grafana. + +--- + +# Documentation complémentaire + +Documentation backend : + +```text +backend/README.md +``` + +Documentation frontend : + +```text +frontend/README.md +``` + +Ces fichiers détaillent respectivement l’API et l’application React. + +--- + +# Projet académique + +Projet réalisé dans le cadre de la certification : + +**Expert en Stratégie et Transformation Digitale** + +Le cas **Thé Tip Top** est utilisé comme contexte métier fictif pour la conception, le développement, les tests, la sécurisation, l’industrialisation et le déploiement de l’application. diff --git a/backend/README.md b/backend/README.md index cfe298e..9aa2615 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,368 +1,285 @@ -# Thé Tip Top – Backend API +# Thé Tip Top – Backend API ## Présentation -Ce projet correspond au backend du jeu-concours organisé par **Thé Tip Top** à l'occasion de l'ouverture de sa 10ᵉ boutique à Nice. +Backend REST du jeu-concours fictif **Thé Tip Top**, réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**. -L'application permet aux clients de participer à un jeu-concours via un code présent sur leur ticket de caisse, de consulter leurs gains et de réclamer leurs lots en boutique. +L’API fournit les services des espaces **CLIENT**, **EMPLOYEE** et **ADMIN** et prend notamment en charge : -Le backend expose une **API** **REST** sécurisée utilisée par : - -- l'application client (participants) -- l'espace employé en boutique -- l'espace administrateur -- les futurs services externes de l'écosystème Thé Tip Top +- inscription, vérification email et connexion ; +- authentification JWT et Google OAuth 2.0 ; +- rôles et autorisations ; +- tickets, participations et gains ; +- remise des lots ; +- configuration des phases du jeu ; +- tirage au sort final ; +- notifications email ; +- administration et statistiques. --- -## Fonctionnalités +## Stack technique -### Authentification +| Technologie | Utilisation | +|---|---| +| Node.js 22 | Runtime | +| Express.js | API REST | +| PostgreSQL 16 | Base de données | +| Prisma ORM 6 | ORM et migrations | +| JWT | Authentification | +| Passport.js | Authentification externe | +| Google OAuth 2.0 | Connexion Google | +| Bcrypt | Hachage des mots de passe | +| Zod | Validation | +| Nodemailer | Emails | +| Helmet | Sécurité HTTP | +| express-rate-limit | Rate limiting | +| Swagger / OpenAPI | Documentation | +| Jest / Supertest | Tests | +| Docker | Conteneurisation | -- Inscription utilisateur -- Connexion sécurisée -- Authentification **JWT** -- Gestion des rôles (**CLIENT**, **EMPLOYEE**, **ADMIN**) -- Hachage des mots de passe avec Bcrypt -- Validation des données avec **Zod** +## Authentification -### Jeu-concours +### Email / mot de passe -- Génération automatique des lots -- Génération des tickets gagnants -- Participation via code ticket unique -- Attribution automatique des gains -- Historique des participations -- Historique des gains remportés +Le parcours comprend l’inscription, le hachage Bcrypt, l’envoi d’un code de vérification par email, la validation du compte, la connexion et la génération d’un JWT. -### Espace Employé +### Google OAuth 2.0 -- Recherche d'un gain via un code ticket -- Vérification du statut du gain -- Validation de la remise d'un lot -- Mise à jour automatique du statut du gain - -### Espace Administrateur - -- Tableau de bord statistique -- Liste des participants -- Liste des contacts ayant accepté les communications marketing -- Création de comptes employés -- Tirage au sort du grand gagnant -- Consultation du gagnant du gros lot - -### Documentation API - -- Documentation interactive avec **Swagger UI** -- Génération automatique de la documentation via **Swagger JSDoc** -- Tests des endpoints directement depuis le navigateur - ---- - -## Architecture Technique - -### Backend - -- Node.js -- Express.js - -### Base de données - -- PostgreSQL -- **Prisma **ORM** 6** -- Prisma Client 6 -- Prisma Migrate -- Prisma Studio - -### Validation des données - -- **Zod** -- Middleware de validation personnalisé - -### Sécurité - -- **JWT** (**JSON** Web Token) -- Bcrypt -- Gestion des rôles et permissions -- Validation des entrées utilisateur - -### Documentation - -- Swagger UI -- Swagger JSDoc -- OpenAPI 3.0 - ---- - -## Installation - -### Cloner le projet - -```bash -git clone -cd the-tip-top-backend +```text +Utilisateur + ↓ +Frontend + ↓ +API /api/auth/google + ↓ +Google OAuth 2.0 + ↓ +Callback backend + ↓ +Création / récupération utilisateur + ↓ +JWT + ↓ +Redirection frontend ``` -### Installer les dépendances +Les secrets OAuth restent hors du dépôt Git. -```bash -npm install -``` - -### Variables d'environnement - -Créer un fichier `.env` à la racine du projet : - - -```env **PORT**=**5000**``` - -```DATABASE_URL="postgresql://postgres:password@localhost:**5432**/the_tip_top_db*``` - -```JWT_SECRET=*your_secret_key" ``` - ---- - -## Base de données - -### Générer les migrations - -```bash -npx prisma migrate dev -``` - -### Générer Prisma Client - -```bash -npx prisma generate - ``` - -### Exécuter le seed de la base - -```bash -npx prisma db seed -``` - -### Générer les tickets du jeu-concours - -```bash -npm run generate:tickets -``` - -### Ouvrir Prisma Studio - -```bash -npx prisma studio -``` - ---- - -## Technologies utilisées - -| Technologie | Version | -| ----------- | ---------------- | -| Node.js | 20+ | -| Express.js | 5 | -| PostgreSQL | 16+ | -| Prisma ORM | 6 | -| JWT | Dernière version | -| Bcrypt | Dernière version | -| Zod | Dernière version | -| Swagger UI | Dernière version | - ---- - -## Lancement du projet - -### Développement - -Le projet utilise **Nodemon** afin de redémarrer automatiquement le serveur lors des modifications du code. - -```bash -npm run dev - -``` - -Serveur : - -```txt [http://localhost:**5000**](http://localhost:**5000**) ``` - ---- - -## Documentation API - -La documentation Swagger est accessible à l'adresse : - -```txt [http://localhost:**5000**/api-docs](http://localhost:**5000**/api-docs) ``` - -Fonctionnalités disponibles : - -- Consultation de toutes les routes -- Visualisation des schémas de données -- Test des endpoints -- Authentification via **JWT** avec le bouton **Authorize** - ---- - -## Gestion des rôles +## Rôles ### CLIENT - -Peut : - -- Participer au jeu-concours -- Consulter ses gains -- Consulter son profil +- participer au jeu ; +- consulter ses gains ; +- gérer son profil. ### EMPLOYEE - -Peut : - -- Rechercher un gain -- Vérifier les informations du gagnant -- Valider la remise d'un lot +- rechercher et vérifier un gain ; +- valider la remise d’un lot. ### ADMIN +- consulter les statistiques ; +- créer des employés ; +- configurer le jeu ; +- effectuer le tirage final ; +- consulter le grand gagnant. -Peut : +## Jeu-concours -- Accéder aux statistiques -- Créer des employés -- Gérer les campagnes emailing -- Effectuer le tirage au sort final -- Consulter le gagnant du gros lot +Phases applicatives : ---- +- `NOT_CONFIGURED` +- `SCHEDULED` +- `OPEN` +- `CLAIM_ONLY` +- `CLOSED` -## Routes API +Principales règles métier : -### Auth +- un ticket ne peut être utilisé qu’une fois ; +- les tickets sont gagnants ; +- les comptes EMPLOYEE et ADMIN ne participent pas comme clients ; +- plusieurs participations d’un client n’augmentent pas ses chances au tirage final ; +- un seul grand gagnant peut être enregistré ; +- le tirage final respecte les contraintes temporelles du jeu. -| Méthode | Route | -| ------- | ------------------ | -| POST | /api/auth/register | -| POST | /api/auth/login | -| GET | /api/auth/me | +## Notifications email -### Game +Nodemailer est utilisé notamment pour la vérification des comptes et les notifications liées aux gains et au tirage final. La configuration SMTP est fournie par variables d’environnement. -| Méthode | Route | -| ------- | --------------------- | -| POST | /api/game/participate | -| GET | /api/game/my-gains | +## Sécurité -### Employee +- Bcrypt ; +- JWT ; +- Google OAuth 2.0 ; +- Zod ; +- contrôle d’accès par rôle ; +- Helmet ; +- rate limiting ; +- secrets externalisés ; +- HTTPS via Traefik ; +- séparation DEV / PREPROD / PROD. -| Méthode | Route | -| ------- | ---------------------------- | -| GET | /api/employee/ticket/ | -| PATCH | /api/employee/claim//deliver | - -### Admin - -| Méthode | Route | -| ------- | -------------------- | -| GET | /api/admin/stats | -| GET | /api/admin/users | -| GET | /api/admin/emailing | -| POST | /api/admin/employees | -| POST | /api/admin/draw | -| GET | /api/admin/draw | - ---- - -## Validation avec Zod - -Toutes les données reçues par l'**API** sont validées avant traitement grâce à **Zod**. - -Exemples : - -- Inscription utilisateur -- Connexion -- Création d'employés -- Participation au jeu-concours -- Validation des paramètres de requête - -Cette approche garantit : - -- Des données cohérentes -- Une meilleure sécurité -- Des messages d'erreur explicites -- Une maintenance facilitée - ---- +> Ne jamais versionner les mots de passe, tokens, secrets JWT, secrets OAuth ou identifiants SMTP. ## Modèle de données principal -### User +- `User` : utilisateurs et rôles CLIENT / EMPLOYEE / ADMIN. +- `Ticket` : code unique, lot associé et état d’utilisation. +- `Lot` : lot, description, valeur et répartition. +- `Participation` : participation d’un utilisateur avec un ticket. +- `Claim` : gestion de la remise du gain. +- `GameSettings` : configuration temporelle du concours. +- `GrandPrizeWinner` : résultat du tirage final. -- **CLIENT** -- **EMPLOYEE** -- **ADMIN** +## Installation locale -### Ticket +Prérequis : Node.js 22, npm et PostgreSQL 16. -- Code unique -- Gain associé -- Utilisé / Non utilisé - -### Lot - -- Nom -- Description -- Valeur -- Pourcentage de répartition - -### Participation - -- Utilisateur -- Ticket -- Lot gagné - -### Claim - -- **PENDING** -- **DELIVERED** -- **CANCELLED** - -### GrandPrizeWinner - -- Gagnant du gros lot -- Date du tirage -- Récompense - ---- - -## Prisma ORM 6 - -Le projet utilise **Prisma **ORM** 6** pour : - -- La modélisation des données -- Les migrations de base de données -- Les requêtes **SQL** typées -- La génération automatique du client Prisma -- L'administration de la base via Prisma Studio - -Commandes utiles : - -```bash -npx prisma migrate dev npx prisma generate npx prisma db seed npx prisma studio +```bash +npm install ``` ---- +Exemple de `.env` local : -## Règles métier +```env +PORT=5000 +NODE_ENV=development +DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db" +JWT_SECRET="change_me" +APP_URL="http://localhost:5000" +FRONTEND_URL="http://localhost:5173" +MAIL_HOST="smtp.example.com" +MAIL_PORT=587 +MAIL_USER="user@example.com" +MAIL_PASS="change_me" +MAIL_FROM="Thé Tip Top " +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" +``` -- Tous les tickets sont gagnants. -- Un ticket ne peut être utilisé qu'une seule fois. -- Les gains sont associés aux tickets dès leur génération. -- Plusieurs participations n'augmentent pas les chances de remporter le gros lot. -- Un seul gagnant du gros lot peut être enregistré. -- Les gains non récupérés dans les délais peuvent être annulés. +Ces valeurs sont uniquement des exemples. ---- +## Prisma -## Auteur +```bash +npx prisma generate +npx prisma migrate dev +npx prisma migrate deploy +npx prisma db seed +npx prisma studio +``` -Projet réalisé dans le cadre de la certification : **Expert en Stratégie et Transformation Digitale** +## Lancement -Client fictif : **Thé Tip Top**. \ No newline at end of file +Développement : + +```bash +npm run dev +``` + +Démarrage standard : + +```bash +npm start +``` + +API locale généralement disponible sur `http://localhost:5000`. + +## Tests + +```bash +npm test +``` + +Les tests Jest/Supertest couvrent notamment l’inscription, la connexion, les autorisations, les participations, les phases du jeu, le tirage final, les notifications, l’authentification Google et des scénarios E2E. + +Dans Jenkins, une base PostgreSQL temporaire est créée. Les migrations et le seed sont exécutés avant les tests, puis les résultats sont publiés au format JUnit. + +## Documentation API + +Swagger UI est accessible localement sur : + +```text +http://localhost:5000/api-docs +``` + +## CI/CD + +Le projet utilise Gitea, Jenkins, Docker et un Registry privé. + +```text +feature/* → CI uniquement +develop → DEV automatique +release-* → PREPROD automatique +main → validation manuelle → PROD +``` + +Pipeline : + +```text +Checkout + ↓ +Tests backend + PostgreSQL temporaire + ↓ +Lint + Build frontend + ↓ +Docker Build + ↓ +Registry + ↓ +Déploiement + ↓ +Contrôle des conteneurs +``` + +## Environnements + +| Environnement | Frontend | API | +|---|---|---| +| DEV | `https://dev.dsp5-archi-o24a-4-5-g4.fr` | `https://api-dev.dsp5-archi-o24a-4-5-g4.fr` | +| PREPROD | `https://preprod.dsp5-archi-o24a-4-5-g4.fr` | `https://api-preprod.dsp5-archi-o24a-4-5-g4.fr` | +| PROD | `https://dsp5-archi-o24a-4-5-g4.fr` | `https://api.dsp5-archi-o24a-4-5-g4.fr` | + +## Infrastructure + +- Docker / Docker Compose ; +- Gitea ; +- Jenkins ; +- Docker Registry ; +- Traefik ; +- Let's Encrypt / HTTPS ; +- PostgreSQL ; +- Prometheus ; +- Grafana ; +- cAdvisor ; +- Node Exporter. + +## Arborescence simplifiée + +```text +backend/ +├── prisma/ +│ ├── migrations/ +│ ├── schema.prisma +│ └── seed.js +├── src/ +│ ├── config/ +│ ├── controllers/ +│ ├── docs/ +│ ├── middlewares/ +│ ├── routes/ +│ ├── utils/ +│ ├── validations/ +│ └── app.js +├── tests/ +├── Dockerfile +├── package.json +└── README.md +``` + +## Projet + +Projet étudiant fictif réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**. + +Client fictif : **Thé Tip Top**. diff --git a/frontend/README.md b/frontend/README.md index a36934d..267a010 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,16 +1,275 @@ -# React + Vite +# Thé Tip Top – Frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +## Présentation -Currently, two official plugins are available: +Frontend React du jeu-concours fictif **Thé Tip Top**, réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**. -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +L’interface communique avec l’API backend et propose des parcours adaptés aux visiteurs, clients, employés et administrateurs. -## React Compiler +## Stack technique -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +| Technologie | Utilisation | +|---|---| +| React | Interface utilisateur | +| Vite | Développement et build | +| React Router | Navigation | +| Tailwind CSS | Interface responsive | +| Vitest | Tests | +| Testing Library | Tests fonctionnels | +| ESLint | Analyse statique | +| Nginx | Serveur web du conteneur | +| Docker | Conteneurisation | -## Expanding the ESLint configuration +## Fonctionnalités -If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. +### Visiteur +- accueil ; +- règles du jeu ; +- inscription ; +- connexion ; +- connexion Google ; +- pages légales et confidentialité ; +- CGU et accessibilité ; +- contact ; +- plan du site. + +### Client +- profil ; +- saisie d’un code unique ; +- participation ; +- consultation des gains ; +- gestion de session. + +### Employé +- recherche d’un gain ; +- vérification d’un ticket ; +- validation de la remise d’un lot. + +### Administrateur +- statistiques ; +- gestion administrative ; +- création d’employés ; +- configuration du jeu ; +- tirage au sort ; +- consultation du grand gagnant. + +## Authentification + +Le frontend prend en charge l’authentification email/mot de passe, la vérification email, le JWT, les routes protégées et Google OAuth 2.0. + +Flux OAuth : + +```text +Frontend + ↓ +/api/auth/google + ↓ +Google + ↓ +Callback backend + ↓ +/auth/callback frontend + ↓ +JWT + utilisateur + ↓ +Redirection selon le rôle +``` + +## Rôles + +- `CLIENT` +- `EMPLOYEE` +- `ADMIN` + +Les protections frontend améliorent l’expérience utilisateur ; les autorisations sensibles restent également contrôlées côté backend. + +## Configuration API + +Le frontend utilise : + +```env +VITE_API_URL=http://localhost:5000 +``` + +Les services utilisent cette URL pour les routes `/api/auth`, `/api/game`, `/api/employee` et `/api/admin`. + +Dans Jenkins, `VITE_API_URL` est injectée au moment du build Docker selon l’environnement cible. + +## Installation locale + +Prérequis : Node.js 22 et npm. + +```bash +npm install +``` + +Puis, si nécessaire : + +```env +VITE_API_URL=http://localhost:5000 +``` + +## Développement + +```bash +npm run dev +``` + +L’application Vite est généralement disponible sur `http://localhost:5173`. + +## Qualité et tests + +Lint : + +```bash +npm run lint +``` + +Tests : + +```bash +npm test -- --run +``` + +Build : + +```bash +npm run build +``` + +Les tests couvrent notamment la connexion, l’inscription, la saisie de code, les routes protégées, le contexte d’authentification, les services et la bannière de cookies. + +## Docker + +Le frontend utilise un build multi-stage : + +```text +Node.js + ↓ +npm ci + ↓ +Vite build + ↓ +dist/ + ↓ +Nginx +``` + +## CI/CD + +```text +feature/* → CI uniquement +develop → DEV automatique +release-* → PREPROD automatique +main → validation manuelle → PROD +``` + +Pour le frontend, Jenkins exécute notamment : + +1. `npm ci` ; +2. `npm run lint` ; +3. `npm run build` ; +4. Docker build ; +5. publication dans le Registry ; +6. déploiement dans l’environnement cible. + +## Environnements + +| Environnement | Frontend | API | +|---|---|---| +| DEV | `https://dev.dsp5-archi-o24a-4-5-g4.fr` | `https://api-dev.dsp5-archi-o24a-4-5-g4.fr` | +| PREPROD | `https://preprod.dsp5-archi-o24a-4-5-g4.fr` | `https://api-preprod.dsp5-archi-o24a-4-5-g4.fr` | +| PROD | `https://dsp5-archi-o24a-4-5-g4.fr` | `https://api.dsp5-archi-o24a-4-5-g4.fr` | + +## Architecture applicative + +```text +Navigateur + ↓ HTTPS +Traefik + ↓ +Nginx / React + ↓ +API Express + ↓ +PostgreSQL +``` + +Le frontend n’accède jamais directement à PostgreSQL. + +## Pages principales + +Le projet comprend notamment les pages suivantes : + +- accueil ; +- inscription ; +- connexion ; +- callback OAuth ; +- définition du mot de passe ; +- saisie du code unique ; +- gains ; +- profil ; +- espace employé ; +- espace administrateur ; +- règles du jeu ; +- contact ; +- mentions légales ; +- confidentialité ; +- CGU ; +- accessibilité ; +- plan du site ; +- page 404. + +## Sécurité + +- routes protégées ; +- gestion centralisée de l’authentification ; +- traitement des erreurs 401 ; +- redirection selon le rôle ; +- HTTPS sur les environnements déployés ; +- aucun secret OAuth privé dans le frontend. + +> Le `GOOGLE_CLIENT_SECRET`, le secret JWT, les identifiants SMTP et les mots de passe de base de données ne doivent jamais être exposés dans le frontend. + +## Arborescence simplifiée + +```text +frontend/ +├── src/ +│ ├── components/ +│ ├── context/ +│ ├── pages/ +│ ├── services/ +│ ├── test/ +│ └── App.jsx +├── Dockerfile +├── nginx.conf +├── package.json +└── README.md +``` + +## Infrastructure globale + +```text +Développeur + ↓ +Gitea + ↓ +Jenkins + ↓ +Tests + Lint + Build + ↓ +Docker Registry + ↓ +DEV / PREPROD / PROD + ↓ +Traefik + HTTPS +``` + +Supervision : Prometheus, Grafana, cAdvisor et Node Exporter. + +## Projet + +Projet étudiant fictif réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**. + +Client fictif : **Thé Tip Top**. From 23c548992d59adfa2e38d0fe417de572bbeb7b14 Mon Sep 17 00:00:00 2001 From: Anilzer Date: Fri, 21 Aug 2026 02:32:55 +0200 Subject: [PATCH 04/13] add readMe Files --- README.md | 781 +++++++++++++++++++++++++++++++++++++++++++++ backend/README.md | 559 ++++++++++++++------------------ frontend/README.md | 277 +++++++++++++++- 3 files changed, 1287 insertions(+), 330 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..a07f9a2 --- /dev/null +++ b/README.md @@ -0,0 +1,781 @@ +# Thé Tip Top – Plateforme du jeu-concours + +## Présentation + +**Thé Tip Top** est une application web complète développée dans le cadre d’un projet de certification **Expert en Stratégie et Transformation Digitale**. + +Le projet simule le système d’information d’un jeu-concours organisé à l’occasion de l’ouverture de la 10ᵉ boutique Thé Tip Top à Nice. + +La plateforme permet aux clients de participer avec un code unique présent sur leur ticket, de consulter leurs gains et de les récupérer en boutique. Elle fournit également des interfaces dédiées aux employés et aux administrateurs. + +Au-delà de l’application, le projet met en œuvre une chaîne complète de **CI/CD**, plusieurs environnements isolés, un registre Docker privé, un reverse proxy HTTPS et une stack de supervision. + +> **Projet étudiant — client fictif Thé Tip Top.** + +--- + +## Fonctionnalités principales + +### Espace client + +- création de compte ; +- vérification de l’adresse email ; +- connexion email / mot de passe ; +- connexion avec Google OAuth 2.0 ; +- gestion du profil ; +- saisie d’un code ticket unique ; +- participation au jeu-concours ; +- consultation des gains. + +### Espace employé + +- authentification avec rôle `EMPLOYEE` ; +- recherche d’un gain ; +- contrôle des informations associées au ticket ; +- validation de la remise d’un lot. + +### Espace administrateur + +- authentification avec rôle `ADMIN` ; +- tableau de bord et statistiques ; +- consultation des participants ; +- gestion des données nécessaires à l’administration ; +- création de comptes employés ; +- configuration du lancement du jeu-concours ; +- tirage au sort final ; +- consultation du grand gagnant. + +### Jeu-concours + +Le système gère plusieurs phases : + +```text +NOT_CONFIGURED + ↓ +SCHEDULED + ↓ +OPEN + ↓ +CLAIM_ONLY + ↓ +CLOSED +``` + +Parmi les principales règles métier : + +- un ticket ne peut être utilisé qu’une seule fois ; +- les tickets du jeu sont gagnants ; +- les gains sont associés aux tickets ; +- seuls les clients participent au jeu ; +- plusieurs participations d’un même client n’augmentent pas ses chances au tirage final ; +- un seul grand gagnant peut être enregistré ; +- le tirage final respecte les contraintes temporelles définies dans l’application. + +--- + +# Architecture générale + +```text + UTILISATEURS + │ + HTTPS + │ + ▼ + ┌─────────┐ + │ Traefik │ + └────┬────┘ + │ + ┌────────────┴────────────┐ + │ │ + ▼ ▼ + Frontend React Backend API + Vite + Nginx Node.js + Express + │ │ + │ ▼ + │ PostgreSQL + │ + Prisma + │ + └────────── API REST ─────┘ +``` + +--- + +# Stack technique + +## Frontend + +- React +- Vite +- React Router +- Tailwind CSS +- Vitest +- Testing Library +- ESLint +- Nginx + +## Backend + +- Node.js 22 +- Express.js +- Prisma ORM 6 +- PostgreSQL 16 +- JWT +- Passport.js +- Google OAuth 2.0 +- Bcrypt +- Zod +- Nodemailer +- Helmet +- express-rate-limit +- Swagger / OpenAPI +- Jest +- Supertest + +## DevOps + +- Git +- Gitea +- Jenkins +- Docker +- Docker Compose +- Docker Registry privé +- Traefik +- HTTPS +- Let's Encrypt + +## Monitoring + +- Prometheus +- Grafana +- cAdvisor +- Node Exporter + +--- + +# Structure du dépôt + +```text +THE-TIP-TOP/ +│ +├── backend/ +│ ├── prisma/ +│ │ ├── migrations/ +│ │ ├── schema.prisma +│ │ └── seed.js +│ ├── src/ +│ ├── tests/ +│ ├── Dockerfile +│ ├── package.json +│ └── README.md +│ +├── frontend/ +│ ├── src/ +│ │ ├── components/ +│ │ ├── context/ +│ │ ├── pages/ +│ │ ├── services/ +│ │ └── test/ +│ ├── Dockerfile +│ ├── nginx.conf +│ ├── package.json +│ └── README.md +│ +├── deploy/ +│ ├── dev/ +│ │ └── docker-compose.yml +│ ├── preprod/ +│ │ └── docker-compose.yml +│ └── prod/ +│ └── docker-compose.yml +│ +├── Jenkinsfile +└── README.md +``` + +Pour les informations spécifiques à chaque application : + +- [`backend/README.md`](backend/README.md) +- [`frontend/README.md`](frontend/README.md) + +--- + +# Environnements + +Le projet utilise trois environnements distincts. + +| Environnement | Branche | Frontend | API | +|---|---|---|---| +| DEV | `develop` | `https://dev.dsp5-archi-o24a-4-5-g4.fr` | `https://api-dev.dsp5-archi-o24a-4-5-g4.fr` | +| PREPROD | `release-*` | `https://preprod.dsp5-archi-o24a-4-5-g4.fr` | `https://api-preprod.dsp5-archi-o24a-4-5-g4.fr` | +| PROD | `main` | `https://dsp5-archi-o24a-4-5-g4.fr` | `https://api.dsp5-archi-o24a-4-5-g4.fr` | + +Les bases PostgreSQL et conteneurs applicatifs sont séparés entre les environnements. + +--- + +# Stratégie Git + +Le workflow de branches est organisé autour de plusieurs niveaux de validation. + +```text +feature/* + │ + │ Pull Request / Merge + ▼ +develop + │ + │ CI + déploiement DEV + ▼ +release-* + │ + │ CI + déploiement PREPROD + ▼ +main + │ + │ validation manuelle Jenkins + ▼ +PRODUCTION +``` + +### `feature/*` + +Branches utilisées pour le développement des fonctionnalités. + +### `develop` + +Branche d’intégration. Un changement validé sur cette branche est déployé automatiquement en DEV. + +### `release-*` + +Branches de préparation d’une version. Elles sont déployées en PREPROD pour validation avant production. + +### `main` + +Branche stable de production. Le déploiement PROD nécessite une validation manuelle dans Jenkins. + +--- + +# Pipeline CI/CD + +Le fichier `Jenkinsfile` placé à la racine du dépôt définit la chaîne d’intégration et de déploiement. + +```text +Push Gitea + │ + ▼ +Jenkins + │ + ├── Checkout + │ + ├── Vérification workspace + │ + ├── PostgreSQL CI temporaire + │ + ├── Prisma migrations + │ + ├── Prisma seed + │ + ├── Tests backend Jest + │ + ├── Publication JUnit + │ + ├── Frontend ESLint + │ + ├── Frontend Vite build + │ + ├── Docker build + │ + ├── Push Registry privé + │ + └── Docker Compose deploy + │ + ▼ +DEV / PREPROD / PROD +``` + +## Comportement selon la branche + +### Feature + +```text +Push + ↓ +Tests + ↓ +Lint + ↓ +Build +``` + +Pas de déploiement automatique. + +### Develop + +```text +Push develop + ↓ +CI + ↓ +Images Docker + ↓ +Registry + ↓ +Déploiement DEV +``` + +### Release + +```text +Push release-* + ↓ +CI + ↓ +Images Docker + ↓ +Registry + ↓ +Déploiement PREPROD +``` + +### Main + +```text +Push main + ↓ +CI + ↓ +Images Docker + ↓ +Registry + ↓ +Validation Jenkins + ↓ +Déploiement PROD +``` + +Cette validation constitue une barrière supplémentaire avant la mise en production. + +--- + +# Tests automatisés + +## Backend + +Les tests utilisent **Jest** et **Supertest**. + +Ils couvrent notamment : + +- inscription ; +- connexion ; +- parcours E2E ; +- autorisations ; +- participation ; +- phases du jeu ; +- tirage au sort ; +- notifications liées aux gains ; +- notification du grand gagnant ; +- authentification Google ; +- gestion du mot de passe ; +- comportements liés aux emails. + +Commande : + +```bash +cd backend +npm test +``` + +Dans Jenkins, PostgreSQL est lancé temporairement pour les tests. Les migrations et données initiales sont appliquées avant l’exécution des suites. + +## Frontend + +Les tests utilisent **Vitest** et **Testing Library**. + +Ils couvrent notamment : + +- connexion ; +- inscription ; +- formulaire d’inscription ; +- saisie du code unique ; +- routes protégées ; +- contexte d’authentification ; +- gestion des erreurs d’authentification ; +- services ; +- bannière de cookies. + +Commandes : + +```bash +cd frontend +npm run lint +npm test -- --run +npm run build +``` + +--- + +# Authentification et sécurité + +Le projet met en œuvre plusieurs mécanismes de sécurité. + +## Authentification + +- JWT ; +- email / mot de passe ; +- Google OAuth 2.0 ; +- vérification email. + +## Protection des mots de passe + +Les mots de passe sont hachés avec **Bcrypt** avant stockage. + +## Validation + +Les données reçues par l’API sont validées avec **Zod**. + +## Contrôle d’accès + +Des middlewares backend contrôlent : + +- l’authentification ; +- le rôle ; +- l’accès aux routes protégées. + +## Sécurité HTTP + +Le backend utilise notamment : + +- Helmet ; +- rate limiting ; +- validation des entrées. + +## HTTPS + +Traefik assure le routage HTTP/HTTPS vers les différents services. + +--- + +# Gestion des secrets + +Les secrets ne doivent jamais être stockés directement dans le dépôt Git. + +Sont notamment concernés : + +```text +JWT_SECRET +DATABASE_URL / mots de passe PostgreSQL +MAIL_USER +MAIL_PASS +GOOGLE_CLIENT_ID +GOOGLE_CLIENT_SECRET +FACEBOOK_APP_ID +FACEBOOK_APP_SECRET +identifiants du Docker Registry +``` + +Les valeurs sensibles de déploiement sont externalisées sur le serveur ou injectées par Jenkins. + +> Les fichiers contenant des secrets réels ne doivent jamais être ajoutés au dépôt. + +--- + +# Docker + +Le frontend et le backend disposent chacun de leur propre image Docker. + +## Backend + +```text +Node.js 22 + ↓ +npm ci + ↓ +Prisma generate + ↓ +Application Express +``` + +## Frontend + +Build multi-stage : + +```text +Node.js 22 + ↓ +npm ci + ↓ +Vite build + ↓ +Nginx +``` + +Les images sont versionnées et publiées dans le Registry privé par Jenkins. + +--- + +# Docker Compose + +Chaque environnement possède son propre fichier Compose : + +```text +deploy/dev/docker-compose.yml +deploy/preprod/docker-compose.yml +deploy/prod/docker-compose.yml +``` + +Chaque stack contient notamment : + +```text +Frontend +Backend +PostgreSQL +``` + +Les stacks sont isolées avec leurs propres conteneurs, réseaux, données et paramètres d’environnement. + +--- + +# Reverse proxy et HTTPS + +**Traefik** constitue le point d’entrée HTTP/HTTPS de l’infrastructure. + +Il permet notamment : + +- le routage par nom de domaine ; +- l’exposition des frontends ; +- l’exposition des APIs ; +- la terminaison TLS ; +- la gestion des certificats HTTPS. + +Seuls les ports nécessaires sont exposés publiquement. + +--- + +# Monitoring + +La plateforme dispose d’une stack de supervision. + +```text +Node Exporter ──┐ + │ +cAdvisor ───────┼──► Prometheus ───► Grafana + │ +Services ───────┘ +``` + +### Prometheus + +Collecte les métriques. + +### Grafana + +Permet de construire et consulter les tableaux de bord. + +### cAdvisor + +Expose les métriques relatives aux conteneurs Docker. + +### Node Exporter + +Expose les métriques système du serveur. + +--- + +# Documentation API + +Le backend expose une documentation Swagger / OpenAPI. + +En environnement local : + +```text +http://localhost:5000/api-docs +``` + +Elle permet de : + +- consulter les routes ; +- consulter les schémas ; +- examiner les paramètres ; +- tester les endpoints ; +- utiliser l’authentification prévue par l’API. + +--- + +# Installation locale + +## 1. Cloner le dépôt + +```bash +git clone +cd THE-TIP-TOP +``` + +## 2. Backend + +```bash +cd backend +npm install +``` + +Configurer les variables d’environnement locales puis : + +```bash +npx prisma generate +npx prisma migrate dev +npx prisma db seed +npm run dev +``` + +## 3. Frontend + +Dans un second terminal : + +```bash +cd frontend +npm install +npm run dev +``` + +Le frontend local est généralement accessible sur : + +```text +http://localhost:5173 +``` + +et l’API sur : + +```text +http://localhost:5000 +``` + +Consulter les README des sous-projets pour les détails de configuration. + +--- + +# Base de données + +Le projet utilise **PostgreSQL 16** avec **Prisma ORM 6**. + +Commandes Prisma utiles : + +```bash +npx prisma generate +npx prisma migrate dev +npx prisma migrate deploy +npx prisma db seed +npx prisma studio +``` + +En environnement déployé, les migrations sont appliquées avec `prisma migrate deploy`. + +--- + +# Qualité logicielle + +Le projet applique plusieurs niveaux de contrôle : + +```text +Code + ↓ +ESLint + ↓ +Tests unitaires / fonctionnels + ↓ +Tests backend / E2E + ↓ +Build + ↓ +Docker Build + ↓ +Déploiement +``` + +Un échec dans une étape critique provoque l’échec du pipeline et empêche la promotion normale de la version. + +--- + +# Infrastructure DevOps + +Vue simplifiée : + +```text + ┌───────────────┐ + │ Gitea │ + └───────┬───────┘ + │ Webhook + ▼ + ┌───────────────┐ + │ Jenkins │ + └───────┬───────┘ + │ + Tests / Build / Docker + │ + ▼ + ┌───────────────┐ + │Docker Registry│ + └───────┬───────┘ + │ + ┌───────────────┼───────────────┐ + ▼ ▼ ▼ + DEV PREPROD PROD + │ │ │ + └───────────────┼───────────────┘ + ▼ + Traefik + │ + HTTPS +``` + +--- + +# État du projet + +Le projet dispose notamment de : + +- frontend React opérationnel ; +- backend Express opérationnel ; +- PostgreSQL et Prisma ; +- authentification JWT ; +- Google OAuth 2.0 ; +- vérification email ; +- gestion CLIENT / EMPLOYEE / ADMIN ; +- jeu-concours et gestion des gains ; +- tirage final ; +- tests automatisés ; +- documentation Swagger ; +- images Docker ; +- Registry privé ; +- pipeline Jenkins multibranch ; +- environnements DEV / PREPROD / PROD ; +- déploiements Docker Compose ; +- reverse proxy Traefik ; +- HTTPS ; +- monitoring Prometheus / Grafana. + +--- + +# Documentation complémentaire + +Documentation backend : + +```text +backend/README.md +``` + +Documentation frontend : + +```text +frontend/README.md +``` + +Ces fichiers détaillent respectivement l’API et l’application React. + +--- + +# Projet académique + +Projet réalisé dans le cadre de la certification : + +**Expert en Stratégie et Transformation Digitale** + +Le cas **Thé Tip Top** est utilisé comme contexte métier fictif pour la conception, le développement, les tests, la sécurisation, l’industrialisation et le déploiement de l’application. diff --git a/backend/README.md b/backend/README.md index cfe298e..9aa2615 100644 --- a/backend/README.md +++ b/backend/README.md @@ -1,368 +1,285 @@ -# Thé Tip Top – Backend API +# Thé Tip Top – Backend API ## Présentation -Ce projet correspond au backend du jeu-concours organisé par **Thé Tip Top** à l'occasion de l'ouverture de sa 10ᵉ boutique à Nice. +Backend REST du jeu-concours fictif **Thé Tip Top**, réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**. -L'application permet aux clients de participer à un jeu-concours via un code présent sur leur ticket de caisse, de consulter leurs gains et de réclamer leurs lots en boutique. +L’API fournit les services des espaces **CLIENT**, **EMPLOYEE** et **ADMIN** et prend notamment en charge : -Le backend expose une **API** **REST** sécurisée utilisée par : - -- l'application client (participants) -- l'espace employé en boutique -- l'espace administrateur -- les futurs services externes de l'écosystème Thé Tip Top +- inscription, vérification email et connexion ; +- authentification JWT et Google OAuth 2.0 ; +- rôles et autorisations ; +- tickets, participations et gains ; +- remise des lots ; +- configuration des phases du jeu ; +- tirage au sort final ; +- notifications email ; +- administration et statistiques. --- -## Fonctionnalités +## Stack technique -### Authentification +| Technologie | Utilisation | +|---|---| +| Node.js 22 | Runtime | +| Express.js | API REST | +| PostgreSQL 16 | Base de données | +| Prisma ORM 6 | ORM et migrations | +| JWT | Authentification | +| Passport.js | Authentification externe | +| Google OAuth 2.0 | Connexion Google | +| Bcrypt | Hachage des mots de passe | +| Zod | Validation | +| Nodemailer | Emails | +| Helmet | Sécurité HTTP | +| express-rate-limit | Rate limiting | +| Swagger / OpenAPI | Documentation | +| Jest / Supertest | Tests | +| Docker | Conteneurisation | -- Inscription utilisateur -- Connexion sécurisée -- Authentification **JWT** -- Gestion des rôles (**CLIENT**, **EMPLOYEE**, **ADMIN**) -- Hachage des mots de passe avec Bcrypt -- Validation des données avec **Zod** +## Authentification -### Jeu-concours +### Email / mot de passe -- Génération automatique des lots -- Génération des tickets gagnants -- Participation via code ticket unique -- Attribution automatique des gains -- Historique des participations -- Historique des gains remportés +Le parcours comprend l’inscription, le hachage Bcrypt, l’envoi d’un code de vérification par email, la validation du compte, la connexion et la génération d’un JWT. -### Espace Employé +### Google OAuth 2.0 -- Recherche d'un gain via un code ticket -- Vérification du statut du gain -- Validation de la remise d'un lot -- Mise à jour automatique du statut du gain - -### Espace Administrateur - -- Tableau de bord statistique -- Liste des participants -- Liste des contacts ayant accepté les communications marketing -- Création de comptes employés -- Tirage au sort du grand gagnant -- Consultation du gagnant du gros lot - -### Documentation API - -- Documentation interactive avec **Swagger UI** -- Génération automatique de la documentation via **Swagger JSDoc** -- Tests des endpoints directement depuis le navigateur - ---- - -## Architecture Technique - -### Backend - -- Node.js -- Express.js - -### Base de données - -- PostgreSQL -- **Prisma **ORM** 6** -- Prisma Client 6 -- Prisma Migrate -- Prisma Studio - -### Validation des données - -- **Zod** -- Middleware de validation personnalisé - -### Sécurité - -- **JWT** (**JSON** Web Token) -- Bcrypt -- Gestion des rôles et permissions -- Validation des entrées utilisateur - -### Documentation - -- Swagger UI -- Swagger JSDoc -- OpenAPI 3.0 - ---- - -## Installation - -### Cloner le projet - -```bash -git clone -cd the-tip-top-backend +```text +Utilisateur + ↓ +Frontend + ↓ +API /api/auth/google + ↓ +Google OAuth 2.0 + ↓ +Callback backend + ↓ +Création / récupération utilisateur + ↓ +JWT + ↓ +Redirection frontend ``` -### Installer les dépendances +Les secrets OAuth restent hors du dépôt Git. -```bash -npm install -``` - -### Variables d'environnement - -Créer un fichier `.env` à la racine du projet : - - -```env **PORT**=**5000**``` - -```DATABASE_URL="postgresql://postgres:password@localhost:**5432**/the_tip_top_db*``` - -```JWT_SECRET=*your_secret_key" ``` - ---- - -## Base de données - -### Générer les migrations - -```bash -npx prisma migrate dev -``` - -### Générer Prisma Client - -```bash -npx prisma generate - ``` - -### Exécuter le seed de la base - -```bash -npx prisma db seed -``` - -### Générer les tickets du jeu-concours - -```bash -npm run generate:tickets -``` - -### Ouvrir Prisma Studio - -```bash -npx prisma studio -``` - ---- - -## Technologies utilisées - -| Technologie | Version | -| ----------- | ---------------- | -| Node.js | 20+ | -| Express.js | 5 | -| PostgreSQL | 16+ | -| Prisma ORM | 6 | -| JWT | Dernière version | -| Bcrypt | Dernière version | -| Zod | Dernière version | -| Swagger UI | Dernière version | - ---- - -## Lancement du projet - -### Développement - -Le projet utilise **Nodemon** afin de redémarrer automatiquement le serveur lors des modifications du code. - -```bash -npm run dev - -``` - -Serveur : - -```txt [http://localhost:**5000**](http://localhost:**5000**) ``` - ---- - -## Documentation API - -La documentation Swagger est accessible à l'adresse : - -```txt [http://localhost:**5000**/api-docs](http://localhost:**5000**/api-docs) ``` - -Fonctionnalités disponibles : - -- Consultation de toutes les routes -- Visualisation des schémas de données -- Test des endpoints -- Authentification via **JWT** avec le bouton **Authorize** - ---- - -## Gestion des rôles +## Rôles ### CLIENT - -Peut : - -- Participer au jeu-concours -- Consulter ses gains -- Consulter son profil +- participer au jeu ; +- consulter ses gains ; +- gérer son profil. ### EMPLOYEE - -Peut : - -- Rechercher un gain -- Vérifier les informations du gagnant -- Valider la remise d'un lot +- rechercher et vérifier un gain ; +- valider la remise d’un lot. ### ADMIN +- consulter les statistiques ; +- créer des employés ; +- configurer le jeu ; +- effectuer le tirage final ; +- consulter le grand gagnant. -Peut : +## Jeu-concours -- Accéder aux statistiques -- Créer des employés -- Gérer les campagnes emailing -- Effectuer le tirage au sort final -- Consulter le gagnant du gros lot +Phases applicatives : ---- +- `NOT_CONFIGURED` +- `SCHEDULED` +- `OPEN` +- `CLAIM_ONLY` +- `CLOSED` -## Routes API +Principales règles métier : -### Auth +- un ticket ne peut être utilisé qu’une fois ; +- les tickets sont gagnants ; +- les comptes EMPLOYEE et ADMIN ne participent pas comme clients ; +- plusieurs participations d’un client n’augmentent pas ses chances au tirage final ; +- un seul grand gagnant peut être enregistré ; +- le tirage final respecte les contraintes temporelles du jeu. -| Méthode | Route | -| ------- | ------------------ | -| POST | /api/auth/register | -| POST | /api/auth/login | -| GET | /api/auth/me | +## Notifications email -### Game +Nodemailer est utilisé notamment pour la vérification des comptes et les notifications liées aux gains et au tirage final. La configuration SMTP est fournie par variables d’environnement. -| Méthode | Route | -| ------- | --------------------- | -| POST | /api/game/participate | -| GET | /api/game/my-gains | +## Sécurité -### Employee +- Bcrypt ; +- JWT ; +- Google OAuth 2.0 ; +- Zod ; +- contrôle d’accès par rôle ; +- Helmet ; +- rate limiting ; +- secrets externalisés ; +- HTTPS via Traefik ; +- séparation DEV / PREPROD / PROD. -| Méthode | Route | -| ------- | ---------------------------- | -| GET | /api/employee/ticket/ | -| PATCH | /api/employee/claim//deliver | - -### Admin - -| Méthode | Route | -| ------- | -------------------- | -| GET | /api/admin/stats | -| GET | /api/admin/users | -| GET | /api/admin/emailing | -| POST | /api/admin/employees | -| POST | /api/admin/draw | -| GET | /api/admin/draw | - ---- - -## Validation avec Zod - -Toutes les données reçues par l'**API** sont validées avant traitement grâce à **Zod**. - -Exemples : - -- Inscription utilisateur -- Connexion -- Création d'employés -- Participation au jeu-concours -- Validation des paramètres de requête - -Cette approche garantit : - -- Des données cohérentes -- Une meilleure sécurité -- Des messages d'erreur explicites -- Une maintenance facilitée - ---- +> Ne jamais versionner les mots de passe, tokens, secrets JWT, secrets OAuth ou identifiants SMTP. ## Modèle de données principal -### User +- `User` : utilisateurs et rôles CLIENT / EMPLOYEE / ADMIN. +- `Ticket` : code unique, lot associé et état d’utilisation. +- `Lot` : lot, description, valeur et répartition. +- `Participation` : participation d’un utilisateur avec un ticket. +- `Claim` : gestion de la remise du gain. +- `GameSettings` : configuration temporelle du concours. +- `GrandPrizeWinner` : résultat du tirage final. -- **CLIENT** -- **EMPLOYEE** -- **ADMIN** +## Installation locale -### Ticket +Prérequis : Node.js 22, npm et PostgreSQL 16. -- Code unique -- Gain associé -- Utilisé / Non utilisé - -### Lot - -- Nom -- Description -- Valeur -- Pourcentage de répartition - -### Participation - -- Utilisateur -- Ticket -- Lot gagné - -### Claim - -- **PENDING** -- **DELIVERED** -- **CANCELLED** - -### GrandPrizeWinner - -- Gagnant du gros lot -- Date du tirage -- Récompense - ---- - -## Prisma ORM 6 - -Le projet utilise **Prisma **ORM** 6** pour : - -- La modélisation des données -- Les migrations de base de données -- Les requêtes **SQL** typées -- La génération automatique du client Prisma -- L'administration de la base via Prisma Studio - -Commandes utiles : - -```bash -npx prisma migrate dev npx prisma generate npx prisma db seed npx prisma studio +```bash +npm install ``` ---- +Exemple de `.env` local : -## Règles métier +```env +PORT=5000 +NODE_ENV=development +DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db" +JWT_SECRET="change_me" +APP_URL="http://localhost:5000" +FRONTEND_URL="http://localhost:5173" +MAIL_HOST="smtp.example.com" +MAIL_PORT=587 +MAIL_USER="user@example.com" +MAIL_PASS="change_me" +MAIL_FROM="Thé Tip Top " +GOOGLE_CLIENT_ID="" +GOOGLE_CLIENT_SECRET="" +``` -- Tous les tickets sont gagnants. -- Un ticket ne peut être utilisé qu'une seule fois. -- Les gains sont associés aux tickets dès leur génération. -- Plusieurs participations n'augmentent pas les chances de remporter le gros lot. -- Un seul gagnant du gros lot peut être enregistré. -- Les gains non récupérés dans les délais peuvent être annulés. +Ces valeurs sont uniquement des exemples. ---- +## Prisma -## Auteur +```bash +npx prisma generate +npx prisma migrate dev +npx prisma migrate deploy +npx prisma db seed +npx prisma studio +``` -Projet réalisé dans le cadre de la certification : **Expert en Stratégie et Transformation Digitale** +## Lancement -Client fictif : **Thé Tip Top**. \ No newline at end of file +Développement : + +```bash +npm run dev +``` + +Démarrage standard : + +```bash +npm start +``` + +API locale généralement disponible sur `http://localhost:5000`. + +## Tests + +```bash +npm test +``` + +Les tests Jest/Supertest couvrent notamment l’inscription, la connexion, les autorisations, les participations, les phases du jeu, le tirage final, les notifications, l’authentification Google et des scénarios E2E. + +Dans Jenkins, une base PostgreSQL temporaire est créée. Les migrations et le seed sont exécutés avant les tests, puis les résultats sont publiés au format JUnit. + +## Documentation API + +Swagger UI est accessible localement sur : + +```text +http://localhost:5000/api-docs +``` + +## CI/CD + +Le projet utilise Gitea, Jenkins, Docker et un Registry privé. + +```text +feature/* → CI uniquement +develop → DEV automatique +release-* → PREPROD automatique +main → validation manuelle → PROD +``` + +Pipeline : + +```text +Checkout + ↓ +Tests backend + PostgreSQL temporaire + ↓ +Lint + Build frontend + ↓ +Docker Build + ↓ +Registry + ↓ +Déploiement + ↓ +Contrôle des conteneurs +``` + +## Environnements + +| Environnement | Frontend | API | +|---|---|---| +| DEV | `https://dev.dsp5-archi-o24a-4-5-g4.fr` | `https://api-dev.dsp5-archi-o24a-4-5-g4.fr` | +| PREPROD | `https://preprod.dsp5-archi-o24a-4-5-g4.fr` | `https://api-preprod.dsp5-archi-o24a-4-5-g4.fr` | +| PROD | `https://dsp5-archi-o24a-4-5-g4.fr` | `https://api.dsp5-archi-o24a-4-5-g4.fr` | + +## Infrastructure + +- Docker / Docker Compose ; +- Gitea ; +- Jenkins ; +- Docker Registry ; +- Traefik ; +- Let's Encrypt / HTTPS ; +- PostgreSQL ; +- Prometheus ; +- Grafana ; +- cAdvisor ; +- Node Exporter. + +## Arborescence simplifiée + +```text +backend/ +├── prisma/ +│ ├── migrations/ +│ ├── schema.prisma +│ └── seed.js +├── src/ +│ ├── config/ +│ ├── controllers/ +│ ├── docs/ +│ ├── middlewares/ +│ ├── routes/ +│ ├── utils/ +│ ├── validations/ +│ └── app.js +├── tests/ +├── Dockerfile +├── package.json +└── README.md +``` + +## Projet + +Projet étudiant fictif réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**. + +Client fictif : **Thé Tip Top**. diff --git a/frontend/README.md b/frontend/README.md index a36934d..267a010 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,16 +1,275 @@ -# React + Vite +# Thé Tip Top – Frontend -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +## Présentation -Currently, two official plugins are available: +Frontend React du jeu-concours fictif **Thé Tip Top**, réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**. -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) +L’interface communique avec l’API backend et propose des parcours adaptés aux visiteurs, clients, employés et administrateurs. -## React Compiler +## Stack technique -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). +| Technologie | Utilisation | +|---|---| +| React | Interface utilisateur | +| Vite | Développement et build | +| React Router | Navigation | +| Tailwind CSS | Interface responsive | +| Vitest | Tests | +| Testing Library | Tests fonctionnels | +| ESLint | Analyse statique | +| Nginx | Serveur web du conteneur | +| Docker | Conteneurisation | -## Expanding the ESLint configuration +## Fonctionnalités -If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. +### Visiteur +- accueil ; +- règles du jeu ; +- inscription ; +- connexion ; +- connexion Google ; +- pages légales et confidentialité ; +- CGU et accessibilité ; +- contact ; +- plan du site. + +### Client +- profil ; +- saisie d’un code unique ; +- participation ; +- consultation des gains ; +- gestion de session. + +### Employé +- recherche d’un gain ; +- vérification d’un ticket ; +- validation de la remise d’un lot. + +### Administrateur +- statistiques ; +- gestion administrative ; +- création d’employés ; +- configuration du jeu ; +- tirage au sort ; +- consultation du grand gagnant. + +## Authentification + +Le frontend prend en charge l’authentification email/mot de passe, la vérification email, le JWT, les routes protégées et Google OAuth 2.0. + +Flux OAuth : + +```text +Frontend + ↓ +/api/auth/google + ↓ +Google + ↓ +Callback backend + ↓ +/auth/callback frontend + ↓ +JWT + utilisateur + ↓ +Redirection selon le rôle +``` + +## Rôles + +- `CLIENT` +- `EMPLOYEE` +- `ADMIN` + +Les protections frontend améliorent l’expérience utilisateur ; les autorisations sensibles restent également contrôlées côté backend. + +## Configuration API + +Le frontend utilise : + +```env +VITE_API_URL=http://localhost:5000 +``` + +Les services utilisent cette URL pour les routes `/api/auth`, `/api/game`, `/api/employee` et `/api/admin`. + +Dans Jenkins, `VITE_API_URL` est injectée au moment du build Docker selon l’environnement cible. + +## Installation locale + +Prérequis : Node.js 22 et npm. + +```bash +npm install +``` + +Puis, si nécessaire : + +```env +VITE_API_URL=http://localhost:5000 +``` + +## Développement + +```bash +npm run dev +``` + +L’application Vite est généralement disponible sur `http://localhost:5173`. + +## Qualité et tests + +Lint : + +```bash +npm run lint +``` + +Tests : + +```bash +npm test -- --run +``` + +Build : + +```bash +npm run build +``` + +Les tests couvrent notamment la connexion, l’inscription, la saisie de code, les routes protégées, le contexte d’authentification, les services et la bannière de cookies. + +## Docker + +Le frontend utilise un build multi-stage : + +```text +Node.js + ↓ +npm ci + ↓ +Vite build + ↓ +dist/ + ↓ +Nginx +``` + +## CI/CD + +```text +feature/* → CI uniquement +develop → DEV automatique +release-* → PREPROD automatique +main → validation manuelle → PROD +``` + +Pour le frontend, Jenkins exécute notamment : + +1. `npm ci` ; +2. `npm run lint` ; +3. `npm run build` ; +4. Docker build ; +5. publication dans le Registry ; +6. déploiement dans l’environnement cible. + +## Environnements + +| Environnement | Frontend | API | +|---|---|---| +| DEV | `https://dev.dsp5-archi-o24a-4-5-g4.fr` | `https://api-dev.dsp5-archi-o24a-4-5-g4.fr` | +| PREPROD | `https://preprod.dsp5-archi-o24a-4-5-g4.fr` | `https://api-preprod.dsp5-archi-o24a-4-5-g4.fr` | +| PROD | `https://dsp5-archi-o24a-4-5-g4.fr` | `https://api.dsp5-archi-o24a-4-5-g4.fr` | + +## Architecture applicative + +```text +Navigateur + ↓ HTTPS +Traefik + ↓ +Nginx / React + ↓ +API Express + ↓ +PostgreSQL +``` + +Le frontend n’accède jamais directement à PostgreSQL. + +## Pages principales + +Le projet comprend notamment les pages suivantes : + +- accueil ; +- inscription ; +- connexion ; +- callback OAuth ; +- définition du mot de passe ; +- saisie du code unique ; +- gains ; +- profil ; +- espace employé ; +- espace administrateur ; +- règles du jeu ; +- contact ; +- mentions légales ; +- confidentialité ; +- CGU ; +- accessibilité ; +- plan du site ; +- page 404. + +## Sécurité + +- routes protégées ; +- gestion centralisée de l’authentification ; +- traitement des erreurs 401 ; +- redirection selon le rôle ; +- HTTPS sur les environnements déployés ; +- aucun secret OAuth privé dans le frontend. + +> Le `GOOGLE_CLIENT_SECRET`, le secret JWT, les identifiants SMTP et les mots de passe de base de données ne doivent jamais être exposés dans le frontend. + +## Arborescence simplifiée + +```text +frontend/ +├── src/ +│ ├── components/ +│ ├── context/ +│ ├── pages/ +│ ├── services/ +│ ├── test/ +│ └── App.jsx +├── Dockerfile +├── nginx.conf +├── package.json +└── README.md +``` + +## Infrastructure globale + +```text +Développeur + ↓ +Gitea + ↓ +Jenkins + ↓ +Tests + Lint + Build + ↓ +Docker Registry + ↓ +DEV / PREPROD / PROD + ↓ +Traefik + HTTPS +``` + +Supervision : Prometheus, Grafana, cAdvisor et Node Exporter. + +## Projet + +Projet étudiant fictif réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**. + +Client fictif : **Thé Tip Top**. From b1272bd008b41f6c579c4e3d9088a8173ac7c5d4 Mon Sep 17 00:00:00 2001 From: Alexis SANOGO Date: Thu, 27 Aug 2026 15:25:53 +0200 Subject: [PATCH 05/13] =?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 06/13] =?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 11/13] 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 12/13] 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 13/13] 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.'