diff --git a/Jenkinsfile b/Jenkinsfile index bfbcfaf..eddb835 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -332,10 +332,11 @@ stage('SonarQube Quality Gate') { backend docker build \ - --build-arg VITE_API_URL="${FRONTEND_API_URL}" \ - --build-arg VITE_GA_ID="${FRONTEND_GA_ID}" \ - -t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \ - frontend + --build-arg VITE_API_URL="${FRONTEND_API_URL}" \ + --build-arg VITE_GA_ID="${FRONTEND_GA_ID}" \ + --build-arg VITE_TURNSTILE_SITE_KEY="${TURNSTILE_SITE_KEY:-}" \ + -t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \ + frontend ''' } } 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/.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/.gitignore b/backend/.gitignore index ae19cfb..d651b47 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -4,3 +4,15 @@ node_modules/ coverage/ dist/ test-results/ +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? \ No newline at end of file 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/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: ["/tests/setupEnv.js"], + clearMocks: true, testTimeout: 10000, diff --git a/backend/package-lock.json b/backend/package-lock.json index 6f13beb..859f24a 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", @@ -5845,6 +5846,17 @@ "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==", + "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/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 1e9a434..e69fe73 100644 --- a/backend/src/config/passport.js +++ b/backend/src/config/passport.js @@ -1,17 +1,22 @@ 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 { normalizeEmail } = require("../validations/email.validation"); + +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 +27,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 +36,9 @@ async function sendPasswordSetupEmail(user) { html: `

Bienvenue ${user.firstname} !

-

Votre compte a été créé avec Google. Vous pouvez continuer à vous +

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.

+ également à votre compte sans passer par ${providerLabel}.

Définir mon mot de passe @@ -42,95 +46,126 @@ async function sendPasswordSetupEmail(user) {

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.

+ connexion ${providerLabel} continuera de fonctionner.

`, }); } +// 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 = normalizeEmail(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/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: ` +
+

Bienvenue ${firstname} !

+

Un compte employé Thé Tip Top vient d'être créé pour vous.

+

Voici vos identifiants de première connexion :

+

+ Email : ${email}
+ Mot de passe provisoire : ${tempPassword} +

+

+ + Me connecter + +

+

Dès votre première connexion, vous devrez + choisir un nouveau mot de passe. Le mot de passe provisoire ne fonctionnera + plus ensuite.

+
+ `, + }); + } 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 cef2552..171108a 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -3,6 +3,9 @@ const jwt = require("jsonwebtoken"); 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 { @@ -210,6 +213,7 @@ const login = async (req, res) => { lastname: user.lastname, email: user.email, role: user.role, + mustChangePassword: user.mustChangePassword, }, }); } catch (error) { @@ -220,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: ` +

Réinitialisation du mot de passe

+

Bonjour ${user.firstname},

+

Vous avez demandé à définir un nouveau mot de passe.

+

Cliquez sur le lien ci-dessous pour le choisir :

+ Choisir mon nouveau mot de passe +

Ce lien expire dans 30 minutes.

+

Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.

+ `, + }); +} + +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({ @@ -236,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 = process.env.FRONTEND_URL || "http://localhost:5173"; - 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: ` -

Réinitialisation du mot de passe

-

Bonjour ${user.firstname},

-

Vous avez demandé la réinitialisation de votre mot de passe.

-

Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe :

- Réinitialiser mon mot de passe -

Ce lien expire dans 30 minutes.

-

Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.

- `, - }); - - 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({ @@ -283,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 @@ -325,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, }, }); @@ -341,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." }); @@ -397,7 +427,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; @@ -423,10 +453,15 @@ const oauthCallback = (req, res) => { lastname: user.lastname, email: user.email, role: user.role, + mustChangePassword: user.mustChangePassword, }) ).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( @@ -486,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; @@ -495,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 } }); @@ -531,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/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/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 c697f00..dcb3ed8 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -1,9 +1,11 @@ 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"); 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 @@ -111,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}: @@ -160,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); /** @@ -187,30 +240,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/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/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/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/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/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/drawNotification.test.js b/backend/tests/drawNotification.test.js index 80c26f9..531f2aa 100644 --- a/backend/tests/drawNotification.test.js +++ b/backend/tests/drawNotification.test.js @@ -30,7 +30,30 @@ const jourJ = (d) => data: { startDate: new Date(Date.now() - d * DAY) }, }); + +// Un tirage reel peut exister en base : la table GrandPrizeWinner est unique +// pour tout le jeu. On la met de cote le temps du test, puis on la restaure. +async function mettreDeCoteLeGagnant() { + const existant = await prisma.grandPrizeWinner.findFirst(); + if (existant) await prisma.grandPrizeWinner.delete({ where: { id: existant.id } }); + return existant; +} + +async function restaurerLeGagnant(sauvegarde) { + if (!sauvegarde) return; + await prisma.grandPrizeWinner.deleteMany({}); + await prisma.grandPrizeWinner.create({ + data: { + id: sauvegarde.id, + userId: sauvegarde.userId, + prize: sauvegarde.prize, + drawnAt: sauvegarde.drawnAt, + }, + }); +} + beforeAll(async () => { + ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant(); await cleanup(); const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); ctx.settingsId = row.id; @@ -51,6 +74,7 @@ beforeAll(async () => { }); afterAll(async () => { + await restaurerLeGagnant(ctx.gagnantSauvegarde); await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } }); await cleanup(); await prisma.$disconnect(); @@ -67,13 +91,16 @@ describe("Notification du grand gagnant", () => { expect(res.body.emailSent).toBe(true); expect(mailer.sendMail).toHaveBeenCalledTimes(1); + // Le tirage est aleatoire parmi tous les participants : on verifie que + // l email part bien au gagnant annonce, quel qu il soit. + const gagnant = res.body.winner.user; const envoi = mailer.sendMail.mock.calls[0][0]; - expect(envoi.to).toBe(ctx.client.email); + expect(envoi.to).toBe(gagnant.email); expect(envoi.subject).toMatch(/gros lot/i); - expect(envoi.html).toContain("Chloe"); + expect(envoi.html).toContain(gagnant.firstname); expect(envoi.html).toContain("360"); - await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } }); + await prisma.grandPrizeWinner.deleteMany({}); }); test("un SMTP en panne n invalide pas le tirage", async () => { @@ -86,10 +113,11 @@ describe("Notification du grand gagnant", () => { expect(res.body.message).toMatch(/contacter directement/); // Le tirage reste enregistre : c est un acte irreversible. - const enBase = await prisma.grandPrizeWinner.findFirst({ where: { userId: ctx.client.id } }); + const enBase = await prisma.grandPrizeWinner.findFirst(); expect(enBase).not.toBeNull(); + expect(enBase.userId).toBe(res.body.winner.user.id); - await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } }); + await prisma.grandPrizeWinner.deleteMany({}); }); test("aucun email n est envoye si le tirage est refuse", async () => { 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>/)[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/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/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/gameFlow.test.js b/backend/tests/gameFlow.test.js index 040b603..76fc5ae 100644 --- a/backend/tests/gameFlow.test.js +++ b/backend/tests/gameFlow.test.js @@ -26,7 +26,30 @@ async function cleanup() { const setStart = (d) => prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: new Date(Date.now() - d * DAY) } }); + +// Un tirage reel peut exister en base : la table GrandPrizeWinner est unique +// pour tout le jeu. On la met de cote le temps du test, puis on la restaure. +async function mettreDeCoteLeGagnant() { + const existant = await prisma.grandPrizeWinner.findFirst(); + if (existant) await prisma.grandPrizeWinner.delete({ where: { id: existant.id } }); + return existant; +} + +async function restaurerLeGagnant(sauvegarde) { + if (!sauvegarde) return; + await prisma.grandPrizeWinner.deleteMany({}); + await prisma.grandPrizeWinner.create({ + data: { + id: sauvegarde.id, + userId: sauvegarde.userId, + prize: sauvegarde.prize, + drawnAt: sauvegarde.drawnAt, + }, + }); +} + beforeAll(async () => { + ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant(); await cleanup(); ctx.settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); ctx.orig = ctx.settings.startDate; @@ -46,6 +69,7 @@ beforeAll(async () => { }); afterAll(async () => { + await restaurerLeGagnant(ctx.gagnantSauvegarde); await prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: ctx.orig } }); await cleanup(); await prisma.$disconnect(); diff --git a/backend/tests/gamePhases.test.js b/backend/tests/gamePhases.test.js index 8f87ea0..432f081 100644 --- a/backend/tests/gamePhases.test.js +++ b/backend/tests/gamePhases.test.js @@ -33,7 +33,25 @@ async function jourJ(offsetJours) { const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "2h" }); + +// La table GrandPrizeWinner est unique pour tout le jeu : on met de cote un +// eventuel tirage reel le temps du test, puis on le restaure. +async function mettreDeCoteLeGagnant() { + const existant = await prisma.grandPrizeWinner.findFirst(); + if (existant) await prisma.grandPrizeWinner.delete({ where: { id: existant.id } }); + return existant; +} + +async function restaurerLeGagnant(sauvegarde) { + if (!sauvegarde) return; + await prisma.grandPrizeWinner.deleteMany({}); + await prisma.grandPrizeWinner.create({ + data: { id: sauvegarde.id, userId: sauvegarde.userId, prize: sauvegarde.prize, drawnAt: sauvegarde.drawnAt }, + }); +} + beforeAll(async () => { + ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant(); await cleanup(); const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); ctx.startDateOriginale = row ? row.startDate : null; @@ -49,6 +67,7 @@ beforeAll(async () => { }); afterAll(async () => { + await restaurerLeGagnant(ctx.gagnantSauvegarde); const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); if (ctx.startDateOriginale) { if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: ctx.startDateOriginale } }); @@ -68,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); @@ -89,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/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/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/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 87014ba..f9ec95f 100644 --- a/deploy/dev/docker-compose.yml +++ b/deploy/dev/docker-compose.yml @@ -40,6 +40,15 @@ services: API_PUBLIC_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr FRONTEND_URL: https://dev.dsp5-archi-o24a-4-5-g4.fr + # Injectés par le pipeline, jamais commités. Vides, les routes de + # connexion externe répondent 503 sans bloquer le démarrage. + GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}" + 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 " npx prisma migrate deploy && diff --git a/deploy/preprod/docker-compose.yml b/deploy/preprod/docker-compose.yml index 9f7c7d2..04ff8b8 100644 --- a/deploy/preprod/docker-compose.yml +++ b/deploy/preprod/docker-compose.yml @@ -40,6 +40,15 @@ services: API_PUBLIC_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr FRONTEND_URL: https://preprod.dsp5-archi-o24a-4-5-g4.fr + # Injectés par le pipeline, jamais commités. Vides, les routes de + # connexion externe répondent 503 sans bloquer le démarrage. + GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}" + 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 " npx prisma migrate deploy && diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml index 2f66ca7..32a1a90 100644 --- a/deploy/prod/docker-compose.yml +++ b/deploy/prod/docker-compose.yml @@ -38,7 +38,16 @@ services: JWT_SECRET: prod_secret_change_me APP_URL: https://api.dsp5-archi-o24a-4-5-g4.fr API_PUBLIC_URL: https://api.dsp5-archi-o24a-4-5-g4.fr - FRONTEND_URL: https://dsp5-archi-o24a-4-5-g4.fr + FRONTEND_URL: https://dsp5-archi-o24a-4-5-g4.fr,https://www.dsp5-archi-o24a-4-5-g4.fr + + # Injectés par le pipeline, jamais commités. Vides, les routes de + # connexion externe répondent 503 sans bloquer le démarrage. + GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}" + 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 fa3424c..91617cf 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 a3816af..dc5a162 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 . . @@ -13,6 +14,9 @@ ARG VITE_GA_ID ENV VITE_API_URL=${VITE_API_URL} ENV VITE_GA_ID=${VITE_GA_ID} +ARG VITE_TURNSTILE_SITE_KEY +ENV VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY} + RUN npm run build 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**. 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/index.html b/frontend/index.html index ee46fd3..42051f7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,43 @@ + Jeu concours - Thé Tip Top + + + + + + + + + + + + + + + + + +
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 3b90ef3..a0187e4 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" @@ -27,6 +28,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" @@ -802,6 +804,15 @@ "@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==", + "peerDependencies": { + "react": "^17.0.2 || ^18.0.0 || ^19.0", + "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -3419,6 +3430,33 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", diff --git a/frontend/package.json b/frontend/package.json index 57932d8..5847540 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", @@ -13,6 +14,7 @@ "test:coverage": "vitest run --coverage" }, "dependencies": { + "@marsidev/react-turnstile": "^1.6.1", "react": "^19.2.6", "react-dom": "^19.2.6", "react-router-dom": "^7.17.0" @@ -32,6 +34,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/public/og-image.webp b/frontend/public/og-image.webp new file mode 100644 index 0000000..37a7063 Binary files /dev/null and b/frontend/public/og-image.webp differ diff --git a/frontend/public/reglement-jeu-concours-100-gagnant.pdf b/frontend/public/reglement-jeu-concours-100-gagnant.pdf new file mode 100644 index 0000000..76b90f3 Binary files /dev/null and b/frontend/public/reglement-jeu-concours-100-gagnant.pdf differ diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt index 86a7682..97232b8 100644 --- a/frontend/public/robots.txt +++ b/frontend/public/robots.txt @@ -3,10 +3,13 @@ User-agent: * # Pages publiques autorisées Allow: / Allow: /accueil +Allow: /notre-concept Allow: /regles-jeu Allow: /contact Allow: /mentions-legales Allow: /confidentialite +Allow: /accessibilite +Allow: /cgu Allow: /sitemap Allow: /inscription Allow: /connexion @@ -20,4 +23,4 @@ Disallow: /saisir-code-unique Disallow: /perdu # Sitemap -Sitemap: https://thetiptop.fr/sitemap +Sitemap: https://www.dsp5-archi-o24a-4-5-g4.fr/sitemap.xml diff --git a/frontend/public/sitemap.xml b/frontend/public/sitemap.xml new file mode 100644 index 0000000..6e83105 --- /dev/null +++ b/frontend/public/sitemap.xml @@ -0,0 +1,14 @@ + + + https://www.dsp5-archi-o24a-4-5-g4.fr/accueil + https://www.dsp5-archi-o24a-4-5-g4.fr/notre-concept + https://www.dsp5-archi-o24a-4-5-g4.fr/regles-jeu + https://www.dsp5-archi-o24a-4-5-g4.fr/contact + https://www.dsp5-archi-o24a-4-5-g4.fr/mentions-legales + https://www.dsp5-archi-o24a-4-5-g4.fr/cgu + https://www.dsp5-archi-o24a-4-5-g4.fr/confidentialite + https://www.dsp5-archi-o24a-4-5-g4.fr/accessibilite + https://www.dsp5-archi-o24a-4-5-g4.fr/sitemap + https://www.dsp5-archi-o24a-4-5-g4.fr/inscription + https://www.dsp5-archi-o24a-4-5-g4.fr/connexion + 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'), `\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/App.jsx b/frontend/src/App.jsx index ff45923..a4a39a6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -7,7 +7,10 @@ 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' import MesGains from './pages/MesGains' import Employe from './pages/Employe' @@ -32,10 +35,13 @@ export default function App() { } /> } /> } /> + } /> } /> + } /> {/* Pages publiques */} } /> + } /> } /> } /> } /> diff --git a/frontend/src/assets/img/banner-accueil.webp b/frontend/src/assets/img/banner-accueil.webp new file mode 100644 index 0000000..44665fb Binary files /dev/null and b/frontend/src/assets/img/banner-accueil.webp differ diff --git a/frontend/src/assets/img/banner-the-tip-top.webp b/frontend/src/assets/img/banner-the-tip-top.webp new file mode 100644 index 0000000..37a7063 Binary files /dev/null and b/frontend/src/assets/img/banner-the-tip-top.webp differ diff --git a/frontend/src/assets/img/img-contact.jpg b/frontend/src/assets/img/img-contact.jpg deleted file mode 100644 index 6c2ae69..0000000 Binary files a/frontend/src/assets/img/img-contact.jpg and /dev/null differ diff --git a/frontend/src/assets/img/img-contact.webp b/frontend/src/assets/img/img-contact.webp new file mode 100644 index 0000000..69f148e Binary files /dev/null and b/frontend/src/assets/img/img-contact.webp differ diff --git a/frontend/src/assets/img/logo.png b/frontend/src/assets/img/logo.png deleted file mode 100644 index 6cf9105..0000000 Binary files a/frontend/src/assets/img/logo.png and /dev/null differ diff --git a/frontend/src/assets/img/logo.webp b/frontend/src/assets/img/logo.webp new file mode 100644 index 0000000..33a20de Binary files /dev/null and b/frontend/src/assets/img/logo.webp differ 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 ( + onToken('')} + onError={() => onToken('')} + /> + ) +} diff --git a/frontend/src/components/Footer.jsx b/frontend/src/components/Footer.jsx index b2189dd..a86e272 100644 --- a/frontend/src/components/Footer.jsx +++ b/frontend/src/components/Footer.jsx @@ -20,7 +20,7 @@ export default function Footer() {