20 Commits

Author SHA1 Message Date
23c548992d add readMe Files
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-23 11:04:09 +00:00
7513bf45d6 release: promote 1.0.3 to production
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-23 10:46:26 +00:00
bdcdf1a4da merge: promote Google Analytics update to release-1.0.3
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-23 10:44:45 +00:00
79e10946b8 merge: integrate Google Analytics
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-23 10:35:40 +00:00
79f463d280 feat: integrate consent-based Google Analytics 2026-08-23 10:35:33 +00:00
ad147eb930 release: promote 1.0.3 to production
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-20 23:55:16 +00:00
a09e9f17b5 fix: load OAuth secrets from server env file
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-20 23:44:36 +00:00
a40a62d600 fix: use backend API URL for Google OAuth
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-20 23:36:54 +00:00
12685c1b22 fix: resolve deployment compose conflicts
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-20 23:27:51 +00:00
3a0dd25ed2 ci: seed test database before backend tests
Some checks failed
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-20 23:15:38 +00:00
a572fa1a15 merge: integrate Google OAuth and application fixes into develop
Some checks failed
Tests / Backend - Tests failed: 25, skipped: 7, passed: 6
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-20 23:09:19 +00:00
f9d72d45e2 merge: integrate Google OAuth and latest application fixes 2026-08-20 23:01:39 +00:00
Emma781227
2cf399d6d0 correction des bugue front end 2026-08-20 21:49:36 +02:00
Emma781227
e895325254 refont de la connexion par google et validation de lenvoie de mail a l'utilisater 2026-08-19 21:56:18 +02:00
Emma781227
fa21d9af2a ajout des champs date de naissance et gender sur l'inscription, modification de la permition de lancer le jeu concour par l'administrateur, envoie des mail a chaque lot remporter 2026-08-18 15:06:51 +02:00
Emma781227
8adf780da2 Variables de connexion externe dans les environnements Docker
Ajoute FRONTEND_URL, absent des trois environnements alors que le callback
OAuth et la liste d'origines CORS en dépendent : sans lui le backend
retombait sur localhost:5173 et refusait le front déployé.

Les identifiants Google et Facebook passent par substitution depuis
l'environnement du pipeline, afin de ne pas committer de secret.

Ignore aussi test-results/, généré par le reporter jest-junit ajouté par la
chaîne CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:25:54 +02:00
Emma781227
bd46e0982e Merge remote-tracking branch 'origin/main' into Emmanuel
Intègre la chaîne CI/CD et la dockerisation venues de main.

Conflits sur backend/package-lock.json et frontend/package-lock.json :
résolus en régénérant les deux lockfiles depuis les package.json fusionnés,
qui réunissent les dépendances des deux branches (helmet, express-rate-limit,
passport, passport-google-oauth20 côté Emmanuel ; jest-junit côté main).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:24:34 +02:00
Emma781227
464d666343 Connexion via Google (OAuth 2.0)
Ajoute l'authentification Google en complément de la connexion par mot de
passe. Le callback OAuth émet le même JWT que la connexion classique, donc
le middleware d'authentification et ProtectedRoute restent inchangés.

Backend
- stratégie passport-google-oauth20 dans src/config/passport.js
- routes GET /api/auth/google et /google/callback, documentées dans Swagger
- un compte existant avec le même email est rattaché automatiquement au
  compte Google, et son email passe vérifié
- les routes répondent 503 si les identifiants ne sont pas configurés,
  plutôt que de faire échouer le démarrage du serveur
- login et changePassword ne comparent plus un hash inexistant sur les
  comptes sans mot de passe

Base de données
- password devient nullable
- colonnes provider, providerId et avatarUrl, index unique sur le couple
  provider/providerId

Frontend
- page /auth/callback qui récupère le token et redirige selon le rôle
- boutons Google de Connexion et Inscription branchés
- boutons Facebook désactivés en attendant les identifiants Meta

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:23:18 +02:00
Emma781227
20c865a071 Sécurisation de l'API et refonte des tests backend
- helmet et express-rate-limit sur les routes d'authentification
- CORS restreint aux origines de FRONTEND_URL
- CSP désactivée uniquement sur /api-docs pour Swagger UI
- ajout du test de tirage et simplification du test e2e
- suppression de tests/setup.js devenu inutile

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 00:23:02 +02:00
Emma781227
a2543cf4ce Merge remote-tracking branch 'origin/Alexis' into Emmanuel
# Conflicts:
#	frontend/src/pages/Contact.jsx
2026-07-27 20:54:11 +02:00
58 changed files with 4149 additions and 1273 deletions

12
Jenkinsfile vendored
View File

@@ -49,6 +49,7 @@ pipeline {
env.DEPLOY_NETWORK = 'tiptop-dev'
env.FRONTEND_API_URL = "https://api-dev.dsp5-archi-o24a-4-5-g4.fr"
env.FRONTEND_GA_ID = ""
} else if (env.BRANCH_NAME.startsWith('release-')) {
env.DEPLOY_ENABLED = 'true'
@@ -59,6 +60,7 @@ pipeline {
env.DEPLOY_NETWORK = 'tiptop-preprod'
env.FRONTEND_API_URL = "https://api-preprod.dsp5-archi-o24a-4-5-g4.fr"
env.FRONTEND_GA_ID = ""
} else if (env.BRANCH_NAME == 'main') {
env.DEPLOY_ENABLED = 'true'
@@ -69,6 +71,7 @@ pipeline {
env.DEPLOY_NETWORK = 'tiptop-prod'
env.FRONTEND_API_URL = "https://api.dsp5-archi-o24a-4-5-g4.fr"
env.FRONTEND_GA_ID = "G-4FTNTPJDRJ"
} else {
env.DEPLOY_ENABLED = 'false'
@@ -78,6 +81,7 @@ pipeline {
env.COMPOSE_PROJECT = ''
env.DEPLOY_NETWORK = ''
env.FRONTEND_API_URL = ''
env.FRONTEND_GA_ID = ""
}
echo "Branche : ${env.BRANCH_NAME}"
@@ -163,6 +167,7 @@ pipeline {
npm ci --prefer-offline --no-audit --no-fund &&
npx prisma generate &&
npx prisma migrate deploy &&
npx prisma db seed &&
npm test
"
'''
@@ -228,9 +233,10 @@ pipeline {
backend
docker build \
--build-arg VITE_API_URL="${FRONTEND_API_URL}" \
-t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \
frontend
--build-arg VITE_API_URL="${FRONTEND_API_URL}" \
--build-arg VITE_GA_ID="${FRONTEND_GA_ID}" \
-t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \
frontend
'''
}
}

781
README.md Normal file
View File

@@ -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 <URL_DU_DEPOT>
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.

View File

@@ -5,8 +5,17 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db"
JWT_SECRET="your_secret_key"
APP_URL="http://localhost:5000"
FRONTEND_URL="http://localhost:5173"
MAIL_HOST="smtp.gmail.com"
MAIL_PORT="587"
MAIL_USER="your_email@gmail.com"
MAIL_PASS="your_app_password"
MAIL_FROM="Thé Tip Top <your_email@gmail.com>"
MAIL_USER=abayomiandil62@gmail.com
MAIL_PASS=dumqamoupdrliygf
MAIL_FROM="Thé Tip Top <abayomiandil62@gmail.com>"
# Connexion externe (OAuth 2.0). Laisser vide désactive proprement le bouton
# correspondant : la route répond 503 au lieu de faire planter le serveur.
# URI de redirection à déclarer côté provider : APP_URL + /api/auth/google/callback
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
FACEBOOK_APP_ID=""
FACEBOOK_APP_SECRET=""

3
backend/.gitignore vendored
View File

@@ -2,4 +2,5 @@ node_modules/
.env
.prisma
coverage/
dist/
dist/
test-results/

View File

@@ -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 <url-du-repository>
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 <user@example.com>"
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**.
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**.

View File

@@ -13,8 +13,12 @@
"bcrypt": "^6.0.0",
"cors": "^2.8.6",
"express": "^5.2.1",
"express-rate-limit": "^8.6.1",
"helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3",
"nodemailer": "^9.0.3",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"prisma": "^6.19.3",
"swagger-jsdoc": "^6.3.0",
"swagger-ui-express": "^5.0.1",
@@ -2197,6 +2201,15 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/base64url": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz",
"integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.40",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
@@ -3210,6 +3223,25 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.6.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz",
"integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"ip-address": "^10.2.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
@@ -3658,6 +3690,18 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -3766,6 +3810,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
"integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -5487,9 +5540,9 @@
}
},
"node_modules/nodemailer": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -5608,6 +5661,12 @@
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
"license": "MIT"
},
"node_modules/oauth": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz",
"integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==",
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5768,6 +5827,64 @@
"node": ">= 0.8"
}
},
"node_modules/passport": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT",
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
"utils-merge": "^1.0.1"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-google-oauth20": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
"integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==",
"license": "MIT",
"dependencies": {
"passport-oauth2": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/passport-oauth2": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz",
"integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==",
"license": "MIT",
"dependencies": {
"base64url": "3.x.x",
"oauth": "0.10.x",
"passport-strategy": "1.x.x",
"uid2": "0.0.x",
"utils-merge": "1.x.x"
},
"engines": {
"node": ">= 0.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/jaredhanson"
}
},
"node_modules/passport-strategy": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz",
"integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -5829,6 +5946,11 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT"
},
"node_modules/pause": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
},
"node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
@@ -6774,6 +6896,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/uid2": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz",
"integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==",
"license": "MIT"
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -6866,6 +6994,15 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/uuid": {
"version": "14.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",

View File

@@ -20,8 +20,12 @@
"bcrypt": "^6.0.0",
"cors": "^2.8.6",
"express": "^5.2.1",
"express-rate-limit": "^8.6.1",
"helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3",
"nodemailer": "^9.0.3",
"passport": "^0.7.0",
"passport-google-oauth20": "^2.0.0",
"prisma": "^6.19.3",
"swagger-jsdoc": "^6.3.0",
"swagger-ui-express": "^5.0.1",

View File

@@ -0,0 +1,11 @@
-- CreateEnum
CREATE TYPE "AuthProvider" AS ENUM ('LOCAL', 'GOOGLE', 'FACEBOOK');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "avatarUrl" TEXT,
ADD COLUMN "provider" "AuthProvider" NOT NULL DEFAULT 'LOCAL',
ADD COLUMN "providerId" TEXT,
ALTER COLUMN "password" DROP NOT NULL;
-- CreateIndex
CREATE UNIQUE INDEX "User_provider_providerId_key" ON "User"("provider", "providerId");

View File

@@ -24,12 +24,20 @@ enum ClaimStatus {
CANCELLED
}
enum AuthProvider {
LOCAL
GOOGLE
FACEBOOK
}
model User {
id Int @id @default(autoincrement())
firstname String
lastname String
email String @unique
password String
// Nul pour les comptes créés via un provider externe (Google, Facebook) :
// ces utilisateurs n'ont jamais choisi de mot de passe chez nous.
password String?
role Role @default(CLIENT)
birthdate DateTime?
gender String?
@@ -43,10 +51,16 @@ model User {
resetPasswordToken String?
resetPasswordExpires DateTime?
provider AuthProvider @default(LOCAL)
providerId String?
avatarUrl String?
tickets Ticket[]
participations Participation[]
grandPrizeWinner GrandPrizeWinner?
claimsValidated Claim[] @relation("EmployeeClaims")
@@unique([provider, providerId])
}
model Lot {

View File

@@ -1,5 +1,7 @@
const express = require("express");
const cors = require("cors");
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
require("dotenv").config();
const authRoutes = require("./routes/auth.routes");
@@ -9,11 +11,37 @@ const adminRoutes = require("./routes/admin.routes");
const swaggerUi = require("swagger-ui-express");
const swaggerSpec = require("./docs/swagger");
const { passport } = require("./config/passport");
const app = express();
app.use(cors());
const allowedOrigins = (process.env.FRONTEND_URL || "http://localhost:5173").split(",");
// La CSP par défaut de helmet bloque les scripts inline de Swagger UI :
// on l'applique partout sauf sur /api-docs, où une CSP dédiée (désactivée)
// est posée juste avant de monter Swagger, plus bas.
app.use((req, res, next) => {
if (req.path.startsWith("/api-docs")) return next();
return helmet()(req, res, next);
});
app.use(cors({ origin: allowedOrigins }));
app.use(express.json());
// OAuth stateless : initialize() suffit, aucune session n'est ouverte.
app.use(passport.initialize());
// Anti-bruteforce sur les routes d'authentification uniquement : 20
// tentatives par IP toutes les 15 minutes, sans limiter le reste de l'API
// (participation au jeu, consultation des gains, etc.).
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 20,
standardHeaders: true,
legacyHeaders: false,
message: { message: "Trop de tentatives, réessayez dans quelques minutes." },
});
app.use("/api/auth/login", authLimiter);
app.use("/api/auth/register", authLimiter);
app.use("/api/auth/forgot-password", authLimiter);
// routes
app.use("/api/auth", authRoutes);
@@ -22,7 +50,7 @@ app.use("/api/employee", employeeRoutes);
app.use("/api/admin", adminRoutes);
// swagger
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
app.use("/api-docs", helmet({ contentSecurityPolicy: false }), swaggerUi.serve, swaggerUi.setup(swaggerSpec));
// test route
app.get("/", (req, res) => {

View File

@@ -0,0 +1,136 @@
const passport = require("passport");
const GoogleStrategy = require("passport-google-oauth20").Strategy;
const crypto = require("crypto");
const prisma = require("./prisma");
const transporter = require("./mailer");
// 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
// reste inaccessible.
const PASSWORD_SETUP_EXPIRY_MS = 1000 * 60 * 60 * 24; // 24 h
async function sendPasswordSetupEmail(user) {
const token = crypto.randomBytes(32).toString("hex");
await prisma.user.update({
where: { id: user.id },
data: {
resetPasswordToken: token,
resetPasswordExpires: new Date(Date.now() + PASSWORD_SETUP_EXPIRY_MS),
},
});
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
const setupUrl = `${frontendUrl}/definir-mot-de-passe/${token}`;
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: user.email,
subject: "Définissez votre mot de passe Thé Tip Top",
html: `
<div style="font-family:sans-serif;max-width:480px;margin:auto">
<h2 style="color:#006B6B">Bienvenue ${user.firstname} !</h2>
<p>Votre compte a été créé avec Google. Vous pouvez continuer à vous
connecter de cette façon, ou définir un mot de passe pour accéder
également à votre compte sans passer par Google.</p>
<p style="text-align:center;padding:20px 0">
<a href="${setupUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
Définir mon mot de passe
</a>
</p>
<p style="color:#888;font-size:13px">Ce lien expire dans 24 heures. Si vous
ne souhaitez pas de mot de passe, ignorez simplement cet email : votre
connexion Google continuera de fonctionner.</p>
</div>
`,
});
}
// Authentification stateless : le callback OAuth émet directement notre JWT,
// donc pas de session Express ni de serialize/deserialize à brancher.
const googleEnabled =
Boolean(process.env.GOOGLE_CLIENT_ID) && Boolean(process.env.GOOGLE_CLIENT_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`,
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);
}
}
)
);
}
module.exports = { passport, googleEnabled };

View File

@@ -1,6 +1,8 @@
const bcrypt = require("bcrypt");
const crypto = require("crypto");
const prisma = require("../config/prisma");
const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow");
const transporter = require("../config/mailer");
const { getGameWindow } = require("../utils/gameWindow");
const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"];
@@ -33,7 +35,6 @@ function computeAgeStats(users) {
const getStats = async (req, res) => {
try {
await expireOverdueClaims();
const gameWindow = await getGameWindow();
const [
@@ -79,9 +80,11 @@ const getStats = async (req, res) => {
ticketsUsed,
ticketsRemaining: ticketsTotal - ticketsUsed,
participations,
claimsPending,
// L'expiration n'est pas écrite en base : une fois la fenêtre de retrait
// fermée, les PENDING restants comptent comme annulés.
claimsPending: gameWindow.isOpen ? claimsPending : 0,
claimsDelivered,
claimsCancelled,
claimsCancelled: gameWindow.isOpen ? claimsCancelled : claimsCancelled + claimsPending,
users,
gameWindow,
ageStats: computeAgeStats(clientsWithBirthdate),
@@ -214,6 +217,31 @@ const createEmployee = async (req, res) => {
const drawWinner = async (req, res) => {
try {
// Le grand gagnant est tiré parmi tous les participants : le tirage n'a de
// sens qu'une fois la période de participation close. Avant l'ouverture,
// il n'existe aucun participant à départager.
const gameWindow = await getGameWindow();
if (!gameWindow.configured) {
return res.status(409).json({
message: "Le jeu-concours n'a pas encore été lancé : aucun tirage au sort n'est possible.",
});
}
if (!gameWindow.hasStarted) {
return res.status(409).json({
message: `Le jeu-concours n'a pas encore commencé. Il ouvre le ${gameWindow.startDate.toLocaleDateString("fr-FR")}.`,
startDate: gameWindow.startDate,
});
}
if (gameWindow.isParticipationOpen) {
return res.status(409).json({
message: `Le tirage au sort ne peut avoir lieu qu'après la fin des participations, le ${gameWindow.participationDeadline.toLocaleDateString("fr-FR")}.`,
participationDeadline: gameWindow.participationDeadline,
});
}
const existingWinner = await prisma.grandPrizeWinner.findFirst({
include: {
user: {
@@ -234,6 +262,10 @@ const drawWinner = async (req, res) => {
});
}
// Un utilisateur = une entrée dans le pool, quel que soit son nombre de
// participations. On sélectionne des CLIENT distincts (pas des
// participations), donc avoir utilisé 10 tickets ou 1 seul ne change
// strictement rien aux chances de gagner le gros lot.
const participants = await prisma.user.findMany({
where: {
role: "CLIENT",
@@ -255,7 +287,11 @@ const drawWinner = async (req, res) => {
});
}
const randomIndex = Math.floor(Math.random() * participants.length);
// Tirage purement aléatoire et équiprobable parmi les participants
// distincts. crypto.randomInt (générateur cryptographiquement sûr,
// sans biais) plutôt que Math.random(), pour un tirage défendable
// devant l'huissier qui le supervise.
const randomIndex = crypto.randomInt(participants.length);
const winner = participants[randomIndex];
const savedWinner = await prisma.grandPrizeWinner.create({
@@ -274,9 +310,41 @@ const drawWinner = async (req, res) => {
},
});
// Le tirage est un acte irréversible réalisé sous contrôle d'huissier :
// un email qui ne part pas ne doit surtout pas l'invalider. On signale
// simplement l'échec pour que l'administrateur prévienne autrement.
let emailSent = true;
try {
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: savedWinner.user.email,
subject: "Vous avez gagné le gros lot Thé Tip Top !",
html: `
<div style="font-family:sans-serif;max-width:480px;margin:auto">
<h2 style="color:#006B6B">Félicitations ${savedWinner.user.firstname} !</h2>
<p>Le tirage au sort du jeu-concours « 100% Gagnant » vient d'avoir lieu,
et c'est vous qui remportez le gros lot :</p>
<div style="font-size:20px;font-weight:bold;color:#006B6B;text-align:center;padding:20px;background:#F0F0F0;border-radius:12px">
${savedWinner.prize}
</div>
<p>Notre équipe vous recontactera très prochainement pour convenir des
modalités de remise de votre lot.</p>
<p style="color:#888;font-size:13px">Merci de votre participation et à bientôt chez Thé Tip Top.</p>
</div>
`,
});
} catch (mailError) {
emailSent = false;
console.error("Erreur envoi email au grand gagnant:", mailError);
}
return res.status(201).json({
message: "Tirage au sort effectué avec succès.",
message: emailSent
? "Tirage au sort effectué avec succès. Le gagnant a été prévenu par email."
: "Tirage au sort effectué avec succès, mais l'email n'a pas pu être envoyé au gagnant. Merci de le contacter directement.",
totalEligibleParticipants: participants.length,
emailSent,
winner: savedWinner,
});
} catch (error) {
@@ -287,6 +355,52 @@ const drawWinner = async (req, res) => {
}
};
// Lancement du jeu-concours : fixe (ou corrige) la date de début, dont
// découlent les deux échéances. Une seule ligne de configuration existe, on
// la met donc à jour si elle est déjà présente.
const setGameSettings = async (req, res) => {
try {
const { startDate } = req.body;
const parsedStartDate = new Date(startDate);
const existing = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
if (existing) {
await prisma.gameSettings.update({
where: { id: existing.id },
data: { startDate: parsedStartDate },
});
} else {
await prisma.gameSettings.create({ data: { startDate: parsedStartDate } });
}
const gameWindow = await getGameWindow();
return res.json({
message: existing
? "Dates du jeu-concours mises à jour."
: "Jeu-concours lancé avec succès.",
gameWindow,
});
} catch (error) {
console.error("Erreur configuration du jeu:", error);
return res.status(500).json({
message: "Erreur serveur lors de la configuration du jeu-concours.",
});
}
};
const getGameSettings = async (req, res) => {
try {
return res.json({ gameWindow: await getGameWindow() });
} catch (error) {
console.error("Erreur lecture configuration du jeu:", error);
return res.status(500).json({
message: "Erreur serveur lors de la lecture de la configuration.",
});
}
};
const getGrandPrizeWinner = async (req, res) => {
try {
const winner = await prisma.grandPrizeWinner.findFirst({
@@ -302,14 +416,8 @@ const getGrandPrizeWinner = async (req, res) => {
},
});
if (!winner) {
return res.status(404).json({
message: "Aucun gagnant du gros lot n'a encore été tiré.",
});
}
return res.json({
winner,
winner: winner ?? null,
});
} catch (error) {
console.error("Erreur récupération gagnant:", error);
@@ -326,4 +434,6 @@ module.exports = {
createEmployee,
drawWinner,
getGrandPrizeWinner,
setGameSettings,
getGameSettings,
};

View File

@@ -62,11 +62,17 @@ const register = async (req, res) => {
},
});
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: email,
subject: "Votre code de validation Thé Tip Top",
html: `
// Le code de validation est le seul moyen d'activer le compte. Si l'envoi
// échoue, laisser l'utilisateur en base le bloquerait définitivement : il
// ne pourrait ni se connecter (email non vérifié) ni se réinscrire (email
// déjà pris). On annule donc la création pour que la tentative reste
// rejouable.
try {
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: email,
subject: "Votre code de validation Thé Tip Top",
html: `
<div style="font-family:sans-serif;max-width:480px;margin:auto">
<h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
<p>Merci pour votre inscription sur Thé Tip Top.</p>
@@ -77,7 +83,21 @@ const register = async (req, res) => {
<p style="color:#888;font-size:13px">Ce code expire dans <strong>15 minutes</strong>.</p>
</div>
`,
});
});
} catch (mailError) {
console.error("Erreur envoi du code de validation:", mailError);
try {
await prisma.user.delete({ where: { id: user.id } });
} catch (rollbackError) {
console.error("Echec de l'annulation de l'inscription:", rollbackError);
}
return res.status(502).json({
message:
"Votre compte n'a pas pu être créé : l'email de validation n'a pas pu être envoyé. Merci de réessayer dans quelques instants.",
});
}
return res.status(201).json({
message: "Inscription réussie. Un code de validation a été envoyé par email.",
@@ -156,6 +176,14 @@ const login = async (req, res) => {
});
}
// Compte créé via un provider externe : aucun mot de passe à comparer.
if (!user.password) {
const providerLabel = user.provider === "FACEBOOK" ? "Facebook" : "Google";
return res.status(401).json({
message: `Ce compte utilise la connexion ${providerLabel}. Utilisez le bouton « ${providerLabel} » pour vous connecter.`,
});
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
@@ -224,7 +252,10 @@ const forgotPassword = async (req, res) => {
},
});
const resetUrl = `${process.env.APP_URL}/api/auth/reset-password/${resetPasswordToken}`;
// 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,
@@ -255,7 +286,7 @@ const forgotPassword = async (req, res) => {
const resetPassword = async (req, res) => {
try {
const { token } = req.params;
const { password } = req.body;
const { password, confirmPassword } = req.body;
if (!password || password.length < 8) {
return res.status(400).json({
@@ -263,6 +294,14 @@ const resetPassword = async (req, res) => {
});
}
// La confirmation est vérifiée côté serveur aussi : le contrôle du
// navigateur ne protège pas un appel direct à l'API.
if (confirmPassword !== undefined && password !== confirmPassword) {
return res.status(400).json({
message: "Les deux mots de passe ne correspondent pas.",
});
}
const user = await prisma.user.findFirst({
where: {
resetPasswordToken: token,
@@ -354,6 +393,50 @@ const verifyCode = async (req, res) => {
}
};
// Fin du parcours OAuth : passport a posé l'utilisateur sur req.user, on émet
// 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";
try {
const user = req.user;
if (!user) {
return res.redirect(
`${frontendUrl}/auth/callback?error=${encodeURIComponent(
"Connexion externe refusée."
)}`
);
}
const token = jwt.sign(
{ id: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "24h" }
);
const payload = Buffer.from(
JSON.stringify({
id: user.id,
firstname: user.firstname,
lastname: user.lastname,
email: user.email,
role: user.role,
})
).toString("base64");
return res.redirect(`${frontendUrl}/auth/callback?token=${token}&user=${payload}`);
} catch (error) {
console.error("Erreur oauthCallback:", error);
return res.redirect(
`${frontendUrl}/auth/callback?error=${encodeURIComponent(
"Erreur serveur lors de la connexion externe."
)}`
);
}
};
const me = async (req, res) => {
return res.json({
user: req.user,
@@ -383,10 +466,13 @@ const deleteAccount = async (req, res) => {
// 4. Supprimer les participations
await tx.participation.deleteMany({ where: { userId } });
// 5. Libérer les tickets utilisés par ce user
// 5. Détacher les tickets du compte sans les remettre en jeu : un code
// figure sur un ticket de caisse physique et n'est jouable qu'une
// fois. Le réinitialiser permettrait de rejouer indéfiniment le même
// code en supprimant puis recréant un compte.
await tx.ticket.updateMany({
where: { userId },
data: { userId: null, isUsed: false, usedAt: null },
data: { userId: null },
});
// 6. Supprimer l'utilisateur
@@ -415,6 +501,13 @@ const changePassword = async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user.password) {
return res.status(400).json({
message:
"Ce compte utilise une connexion externe et n'a pas de mot de passe à modifier.",
});
}
const isValid = await bcrypt.compare(currentPassword, user.password);
if (!isValid) {
return res.status(401).json({ message: "Mot de passe actuel incorrect." });
@@ -440,4 +533,5 @@ module.exports = {
forgotPassword,
changePassword,
deleteAccount,
oauthCallback,
};

View File

@@ -1,11 +1,11 @@
const prisma = require("../config/prisma");
const { expireOverdueClaims } = require("../utils/gameWindow");
const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow");
const getGainByTicketCode = async (req, res) => {
try {
const { code } = req.params;
await expireOverdueClaims();
const gameWindow = await getGameWindow();
const ticket = await prisma.ticket.findUnique({
where: { code: code.toUpperCase() },
@@ -52,7 +52,7 @@ const getGainByTicketCode = async (req, res) => {
},
claim: {
id: ticket.participation.claim.id,
status: ticket.participation.claim.status,
status: effectiveClaimStatus(ticket.participation.claim.status, gameWindow),
claimedAt: ticket.participation.claim.claimedAt,
},
});
@@ -69,7 +69,7 @@ const deliverClaim = async (req, res) => {
const { id } = req.params;
const employeeId = req.user.id;
await expireOverdueClaims();
const gameWindow = await getGameWindow();
const claim = await prisma.claim.findUnique({
where: { id: Number(id) },
@@ -87,7 +87,7 @@ const deliverClaim = async (req, res) => {
});
}
if (claim.status === "CANCELLED") {
if (effectiveClaimStatus(claim.status, gameWindow) === "CANCELLED") {
return res.status(400).json({
message: "Ce gain est expiré et ne peut plus être remis.",
});

View File

@@ -1,5 +1,13 @@
const prisma = require("../config/prisma");
const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow");
const transporter = require("../config/mailer");
const {
getGameWindow,
effectiveClaimStatus,
participationClosedMessage,
} = require("../utils/gameWindow");
// Levée par la transaction quand le ticket a été consommé entre-temps.
class TicketDejaUtiliseError extends Error {}
const participate = async (req, res) => {
try {
@@ -7,9 +15,13 @@ const participate = async (req, res) => {
const userId = req.user.id;
const gameWindow = await getGameWindow();
if (!gameWindow.isOpen) {
// Un code n'est jouable que pendant les 30 jours de jeu : ni avant
// l'ouverture, ni pendant la prolongation réservée aux retraits.
if (!gameWindow.isParticipationOpen) {
return res.status(410).json({
message: "Le jeu-concours est terminé. Vous ne pouvez plus tester de code ni réclamer de lot.",
message: participationClosedMessage(gameWindow),
phase: gameWindow.phase,
});
}
@@ -33,16 +45,24 @@ const participate = async (req, res) => {
}
const result = await prisma.$transaction(async (tx) => {
const updatedTicket = await tx.ticket.update({
where: { id: ticket.id },
// Le contrôle isUsed ci-dessus ne suffit pas : deux requêtes simultanées
// peuvent le franchir ensemble. On repasse donc par un update
// conditionnel, atomique, qui ne touche le ticket que s'il est encore
// libre — le perdant de la course obtient 0 ligne modifiée.
const { count } = await tx.ticket.updateMany({
where: { id: ticket.id, isUsed: false },
data: {
isUsed: true,
usedAt: new Date(),
userId,
},
include: {
lot: true,
},
});
if (count === 0) throw new TicketDejaUtiliseError();
const updatedTicket = await tx.ticket.findUnique({
where: { id: ticket.id },
include: { lot: true },
});
const participation = await tx.participation.create({
@@ -67,8 +87,41 @@ const participate = async (req, res) => {
};
});
// La participation est déjà enregistrée et consultable dans « Mes gains » :
// un email qui ne part pas ne doit pas l'annuler ni consommer le ticket
// pour rien. On se contente de signaler l'échec.
let emailSent = true;
try {
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: req.user.email,
subject: `Vous avez gagné : ${result.ticket.lot.name} !`,
html: `
<div style="font-family:sans-serif;max-width:480px;margin:auto">
<h2 style="color:#006B6B">Bravo ${req.user.firstname} !</h2>
<p>Votre code <strong>${result.ticket.code}</strong> est gagnant. Vous remportez :</p>
<div style="font-size:20px;font-weight:bold;color:#006B6B;text-align:center;padding:20px;background:#F0F0F0;border-radius:12px">
${result.ticket.lot.name}
</div>
${result.ticket.lot.description ? `<p style="text-align:center;color:#666">${result.ticket.lot.description}</p>` : ""}
<p>Présentez-vous en boutique avec ce code pour retirer votre lot${
gameWindow.claimDeadline
? `, avant le <strong>${gameWindow.claimDeadline.toLocaleDateString("fr-FR")}</strong>`
: ""
}.</p>
<p style="color:#888;font-size:13px">Retrouvez à tout moment vos gains dans votre espace « Mes gains ».</p>
</div>
`,
});
} catch (mailError) {
emailSent = false;
console.error("Erreur envoi email de gain:", mailError);
}
return res.status(201).json({
message: "Participation enregistrée avec succès.",
emailSent,
gain: {
name: result.ticket.lot.name,
description: result.ticket.lot.description,
@@ -80,6 +133,12 @@ const participate = async (req, res) => {
},
});
} catch (error) {
if (error instanceof TicketDejaUtiliseError) {
return res.status(409).json({
message: "Ce code a déjà été utilisé.",
});
}
console.error("Erreur participation:", error);
return res.status(500).json({
message: "Erreur serveur lors de la participation.",
@@ -91,7 +150,7 @@ const myGains = async (req, res) => {
try {
const userId = req.user.id;
await expireOverdueClaims();
const gameWindow = await getGameWindow();
const participations = await prisma.participation.findMany({
where: { userId },
@@ -112,7 +171,9 @@ const myGains = async (req, res) => {
lot: participation.lot.name,
description: participation.lot.description,
value: participation.lot.value,
claimStatus: participation.claim?.status,
claimStatus: participation.claim
? effectiveClaimStatus(participation.claim.status, gameWindow)
: undefined,
wonAt: participation.createdAt,
})),
});

View File

@@ -23,6 +23,8 @@ const authMiddleware = async (req, res, next) => {
lastname: true,
email: true,
role: true,
provider: true,
password: true,
},
});
@@ -32,7 +34,11 @@ const authMiddleware = async (req, res, next) => {
});
}
req.user = user;
// 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.
const { password, ...rest } = user;
req.user = { ...rest, hasPassword: Boolean(password) };
next();
} catch (error) {
return res.status(401).json({

View File

@@ -6,12 +6,14 @@ const {
createEmployee,
drawWinner,
getGrandPrizeWinner,
setGameSettings,
getGameSettings,
} = require("../controllers/admin.controller");
const authMiddleware = require("../middlewares/auth.middleware");
const roleMiddleware = require("../middlewares/role.middleware");
const validate = require("../middlewares/validate.middleware");
const { createEmployeeSchema } = require("../validations/admin.validation");
const { createEmployeeSchema, gameSettingsSchema } = require("../validations/admin.validation");
const router = express.Router();
@@ -105,10 +107,48 @@ router.post("/draw", drawWinner);
* - bearerAuth: []
* responses:
* 200:
* description: Gagnant du gros lot
* 404:
* description: Aucun gagnant encore tiré
* description: Gagnant du gros lot, ou { winner - null } si aucun tirage n'a encore eu lieu
*/
router.get("/draw", getGrandPrizeWinner);
/**
* @swagger
* /api/admin/game-settings:
* get:
* summary: Fenêtre du jeu-concours (début, fin des participations, fin des retraits)
* tags:
* - Admin
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Configuration courante du jeu-concours
* put:
* summary: Lance le jeu-concours ou corrige sa date de début
* tags:
* - Admin
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - startDate
* properties:
* startDate:
* type: string
* format: date-time
* example: 2026-09-01T00:00:00.000Z
* responses:
* 200:
* description: Jeu-concours lancé ou mis à jour
* 400:
* description: Date de début invalide
*/
router.get("/game-settings", getGameSettings);
router.put("/game-settings", validate(gameSettingsSchema), setGameSettings);
module.exports = router;

View File

@@ -1,8 +1,9 @@
const express = require("express");
const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount } = require("../controllers/auth.controller");
const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller");
const authMiddleware = require("../middlewares/auth.middleware");
const validate = require("../middlewares/validate.middleware");
const { registerSchema, loginSchema } = require("../validations/auth.validation");
const { passport, googleEnabled } = require("../config/passport");
const router = express.Router();
@@ -161,4 +162,55 @@ router.post("/verify-code", verifyCode);
router.patch("/change-password", authMiddleware, changePassword);
router.delete("/account", authMiddleware, deleteAccount);
/**
* @swagger
* /api/auth/google:
* get:
* summary: Démarre la connexion via Google (redirection OAuth 2.0)
* tags:
* - Auth
* responses:
* 302:
* description: Redirection vers l'écran de consentement Google.
* 503:
* description: Connexion Google non configurée sur ce serveur.
*/
/**
* @swagger
* /api/auth/google/callback:
* get:
* summary: Retour de Google, émet le JWT et redirige vers le front
* tags:
* - Auth
* responses:
* 302:
* description: Redirection vers FRONTEND_URL/auth/callback avec le token.
*/
if (googleEnabled) {
router.get(
"/google",
passport.authenticate("google", { scope: ["profile", "email"], session: false })
);
router.get(
"/google/callback",
passport.authenticate("google", {
session: false,
failureRedirect: `${process.env.FRONTEND_URL || "http://localhost:5173"}/auth/callback?error=${encodeURIComponent(
"Connexion Google 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);
}
module.exports = router;

View File

@@ -1,6 +1,7 @@
const express = require("express");
const { participate, myGains } = require("../controllers/game.controller");
const authMiddleware = require("../middlewares/auth.middleware");
const roleMiddleware = require("../middlewares/role.middleware");
const validate = require("../middlewares/validate.middleware");
const { participateSchema } = require("../validations/game.validation");
@@ -20,7 +21,9 @@ const router = express.Router();
* description: Participation enregistrée
*/
router.post("/participate", authMiddleware, validate(participateSchema), participate);
// Le jeu est reserve aux clients : un ADMIN ou un EMPLOYEE qui joue
// consommerait des tickets et fausserait les statistiques.
router.post("/participate", authMiddleware, roleMiddleware("CLIENT"), validate(participateSchema), participate);
/**
* @swagger
@@ -36,6 +39,6 @@ router.post("/participate", authMiddleware, validate(participateSchema), partici
* description: Liste des gains
*/
router.get("/my-gains", authMiddleware, myGains);
router.get("/my-gains", authMiddleware, roleMiddleware("CLIENT"), myGains);
module.exports = router;

View File

@@ -1,23 +1,41 @@
const prisma = require("../config/prisma");
// Le concours traverse cinq états, et non deux : confondre « pas encore
// ouvert » avec « terminé » produit des messages faux côté joueur et laisse
// passer le tirage au sort avant le début du jeu.
const PHASES = {
NOT_CONFIGURED: "NOT_CONFIGURED",
SCHEDULED: "SCHEDULED",
OPEN: "OPEN",
CLAIM_ONLY: "CLAIM_ONLY",
CLOSED: "CLOSED",
};
const GAME_DURATION_DAYS = 30;
const CLAIM_EXTENSION_DAYS = 30;
const DAY_MS = 24 * 60 * 60 * 1000;
// Fenêtre pendant laquelle un joueur peut à la fois tester son code et
// réclamer son lot : 30 jours de jeu + 30 jours supplémentaires à compter
// de startDate. Tant qu'aucune GameSettings n'existe, le jeu est considéré
// comme non démarré et donc ouvert (comportement de dev par défaut).
async function getGameWindow() {
// Deux échéances distinctes à partir de startDate :
// - participationDeadline (30 j) : dernier moment pour tester un code ;
// - claimDeadline (60 j) : dernier moment pour retirer un lot déjà gagné.
// Les 30 jours supplémentaires servent uniquement au retrait, pas au jeu.
// Tant qu'aucune GameSettings n'existe, le jeu est considéré comme non
// configuré et donc ouvert (comportement de dev par défaut).
async function getGameWindow(now = new Date()) {
const settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
// Tant que l'administrateur n'a pas fixé de date, le concours n'existe pas :
// rien n'est jouable et aucun tirage n'est possible. Ouvrir par défaut
// reviendrait à faire gagner des lots dans un jeu qui n'a jamais été lancé.
if (!settings) {
return {
configured: false,
phase: PHASES.NOT_CONFIGURED,
startDate: null,
participationDeadline: null,
claimDeadline: null,
isOpen: true,
isParticipationOpen: false,
isOpen: false,
};
}
@@ -26,26 +44,57 @@ async function getGameWindow() {
settings.startDate.getTime() + (GAME_DURATION_DAYS + CLAIM_EXTENSION_DAYS) * DAY_MS
);
const hasStarted = now >= settings.startDate;
const isParticipationOpen = hasStarted && now <= participationDeadline;
const isOpen = hasStarted && now <= claimDeadline;
let phase;
if (!hasStarted) phase = PHASES.SCHEDULED;
else if (isParticipationOpen) phase = PHASES.OPEN;
else if (isOpen) phase = PHASES.CLAIM_ONLY;
else phase = PHASES.CLOSED;
return {
configured: true,
phase,
startDate: settings.startDate,
participationDeadline,
claimDeadline,
isOpen: new Date() <= claimDeadline,
hasStarted,
isParticipationOpen,
isOpen,
};
}
// Bascule en CANCELLED les gains PENDING non réclamés une fois la fenêtre
// fermée. Appelé au fil de l'eau (pas de cron) avant toute lecture/écriture
// de claims, ce qui suffit pour ce projet.
async function expireOverdueClaims() {
const window = await getGameWindow();
if (window.isOpen) return;
await prisma.claim.updateMany({
where: { status: "PENDING" },
data: { status: "CANCELLED" },
});
// L'expiration d'un gain est *dérivée* de la fenêtre de jeu, jamais écrite en
// base : un PENDING dont le délai de retrait est passé est présenté comme
// CANCELLED, mais la ligne reste intacte. Corriger une startDate erronée
// suffit donc à rétablir les gains, et aucune lecture ne modifie la base.
function effectiveClaimStatus(status, gameWindow) {
if (status === "PENDING" && !gameWindow.isOpen) return "CANCELLED";
return status;
}
module.exports = { getGameWindow, expireOverdueClaims };
// Message destiné au joueur qui tente de jouer hors de la fenêtre : chaque
// phase a sa propre explication, avec la date utile quand elle existe.
function participationClosedMessage(gameWindow) {
switch (gameWindow.phase) {
case PHASES.NOT_CONFIGURED:
return "Le jeu-concours n'est pas encore ouvert. Revenez prochainement.";
case PHASES.SCHEDULED:
return `Le jeu-concours ouvrira le ${gameWindow.startDate.toLocaleDateString("fr-FR")}.`;
case PHASES.CLAIM_ONLY:
return "La période de participation est terminée. Vous pouvez encore retirer les lots déjà gagnés.";
default:
return "Le jeu-concours est terminé. Vous ne pouvez plus tester de code ni réclamer de lot.";
}
}
module.exports = {
getGameWindow,
effectiveClaimStatus,
participationClosedMessage,
PHASES,
GAME_DURATION_DAYS,
CLAIM_EXTENSION_DAYS,
};

View File

@@ -9,6 +9,13 @@ const createEmployeeSchema = z.object({
gender: z.string().optional(),
});
const gameSettingsSchema = z.object({
startDate: z
.string()
.refine((value) => !Number.isNaN(new Date(value).getTime()), "Date de début invalide"),
});
module.exports = {
createEmployeeSchema,
gameSettingsSchema,
};

View File

@@ -0,0 +1,87 @@
const bcrypt = require("bcrypt");
const prisma = require("../src/config/prisma");
// ------------------------
// Vérifie que le pool de participants au tirage du gros lot ne compte
// chaque client qu'une seule fois, quel que soit son nombre de
// participations. On rejoue ici exactement la requête utilisée par
// admin.controller.js::drawWinner, restreinte à des utilisateurs dédiés
// pour ne pas dépendre des autres données déjà présentes en base
// (et surtout ne jamais toucher aux vrais tickets du jeu-concours).
// ------------------------
let userManyParticipations;
let userOneParticipation;
let ticketIds = [];
beforeAll(async () => {
const lot = await prisma.lot.findFirst();
if (!lot) throw new Error("Aucun lot en base : lancez `npx prisma db seed` avant ce test.");
const hashed = await bcrypt.hash("Password123", 10);
userManyParticipations = await prisma.user.create({
data: {
firstname: "Draw",
lastname: "MultiParticip",
email: "draw-test-multi@test.com",
password: hashed,
role: "CLIENT",
isEmailVerified: true,
},
});
userOneParticipation = await prisma.user.create({
data: {
firstname: "Draw",
lastname: "SingleParticip",
email: "draw-test-single@test.com",
password: hashed,
role: "CLIENT",
isEmailVerified: true,
},
});
const codes = ["ZZDRAWTEST1", "ZZDRAWTEST2", "ZZDRAWTEST3", "ZZDRAWTEST4"];
const owners = [
userManyParticipations,
userManyParticipations,
userManyParticipations,
userOneParticipation,
];
for (let i = 0; i < codes.length; i++) {
const ticket = await prisma.ticket.create({
data: { code: codes[i], lotId: lot.id, isUsed: true, usedAt: new Date(), userId: owners[i].id },
});
ticketIds.push(ticket.id);
await prisma.participation.create({
data: { userId: owners[i].id, ticketId: ticket.id, lotId: lot.id },
});
}
});
afterAll(async () => {
await prisma.participation.deleteMany({ where: { ticketId: { in: ticketIds } } });
await prisma.ticket.deleteMany({ where: { id: { in: ticketIds } } });
await prisma.user.deleteMany({
where: { id: { in: [userManyParticipations.id, userOneParticipation.id] } },
});
await prisma.$disconnect();
});
test("un client avec plusieurs participations ne compte qu'une fois dans le pool du tirage", async () => {
const eligible = await prisma.user.findMany({
where: {
role: "CLIENT",
id: { in: [userManyParticipations.id, userOneParticipation.id] },
participations: { some: {} },
},
select: { id: true },
});
expect(eligible).toHaveLength(2);
expect(eligible.map((u) => u.id).sort()).toEqual(
[userManyParticipations.id, userOneParticipation.id].sort()
);
});

View File

@@ -0,0 +1,106 @@
const request = require("supertest");
const app = require("../src/app");
const prisma = require("../src/config/prisma");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");
const mailer = require("../src/config/mailer");
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
const DAY = 86400000;
const PREFIX = "notif-";
const CODE = "NOTIFTICK1";
let ctx = {};
async function cleanup() {
const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } });
const ids = us.map((u) => u.id);
const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
await prisma.ticket.deleteMany({ where: { code: CODE } });
await prisma.user.deleteMany({ where: { id: { in: ids } } });
}
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
const jourJ = (d) =>
prisma.gameSettings.update({
where: { id: ctx.settingsId },
data: { startDate: new Date(Date.now() - d * DAY) },
});
beforeAll(async () => {
await cleanup();
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
ctx.settingsId = row.id;
ctx.startDateOriginale = row.startDate;
const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
await prisma.ticket.create({ data: { code: CODE, lotId: lot.id } });
const h = await bcrypt.hash("Password123", 10);
ctx.admin = await prisma.user.create({ data: { firstname: "Alice", lastname: "Admin", email: PREFIX + "admin@test.local", password: h, role: "ADMIN", isEmailVerified: true } });
ctx.client = await prisma.user.create({ data: { firstname: "Chloe", lastname: "Martin", email: PREFIX + "chloe@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
ctx.tAdmin = tk(ctx.admin);
await jourJ(1);
mailer.sendMail.mockResolvedValue(true);
await request(app).post("/api/game/participate").set("Authorization", "Bearer " + tk(ctx.client)).send({ code: CODE }).expect(201);
await jourJ(35);
});
afterAll(async () => {
await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } });
await cleanup();
await prisma.$disconnect();
});
describe("Notification du grand gagnant", () => {
beforeEach(() => jest.clearAllMocks());
test("le gagnant recoit un email a son adresse", async () => {
mailer.sendMail.mockResolvedValue(true);
const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin).expect(201);
expect(res.body.emailSent).toBe(true);
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
const envoi = mailer.sendMail.mock.calls[0][0];
expect(envoi.to).toBe(ctx.client.email);
expect(envoi.subject).toMatch(/gros lot/i);
expect(envoi.html).toContain("Chloe");
expect(envoi.html).toContain("360");
await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } });
});
test("un SMTP en panne n invalide pas le tirage", async () => {
mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible"));
const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
expect(res.status).toBe(201);
expect(res.body.emailSent).toBe(false);
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 } });
expect(enBase).not.toBeNull();
await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } });
});
test("aucun email n est envoye si le tirage est refuse", async () => {
mailer.sendMail.mockResolvedValue(true);
await jourJ(1);
const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
expect(res.status).toBe(409);
expect(mailer.sendMail).not.toHaveBeenCalled();
await jourJ(35);
});
});

View File

@@ -10,125 +10,88 @@ jest.mock("../src/config/mailer", () => ({
}));
// ------------------------
// Réinitialisation DB avant tests
// Ce test tourne sur la même base que le reste du dev (500 000 tickets
// réels du jeu-concours). On ne touche donc JAMAIS les tables globales
// (pas de deleteMany sur claim/participation/ticket/user/lot) : tout ce
// que ce fichier crée est identifiable de façon unique et nettoyé dans
// afterAll, rien d'autre.
// ------------------------
const testEmail = "e2e-flow-test@test.com";
const ticketCode = "ZZE2ETEST1";
let token;
let createdTicketId;
beforeAll(async () => {
await prisma.claim.deleteMany();
await prisma.participation.deleteMany();
await prisma.ticket.deleteMany();
await prisma.user.deleteMany();
await prisma.user.deleteMany({ where: { email: testEmail } });
await prisma.ticket.deleteMany({ where: { code: ticketCode } });
});
afterAll(async () => {
const user = await prisma.user.findUnique({ where: { email: testEmail } });
if (user) {
await prisma.claim.deleteMany({ where: { participation: { userId: user.id } } });
await prisma.participation.deleteMany({ where: { userId: user.id } });
}
if (createdTicketId) {
await prisma.ticket.delete({ where: { id: createdTicketId } }).catch(() => {});
}
if (user) {
await prisma.user.delete({ where: { id: user.id } });
}
await prisma.$disconnect();
});
describe("THE TIP TOP - FULL E2E FLOW", () => {
let token;
let ticketCode = "ABC123";
// ------------------------
// 1. REGISTER
// ------------------------
test("Register user", async () => {
const res = await request(app)
.post("/api/auth/register")
.send({
firstname: "Test",
lastname: "User",
email: "test@test.com",
password: "Password123",
});
const res = await request(app).post("/api/auth/register").send({
firstname: "E2E",
lastname: "Test",
email: testEmail,
password: "Password123",
});
expect([201, 409]).toContain(res.statusCode);
expect(res.statusCode).toBe(201);
});
// ------------------------
// 2. LOGIN
// ------------------------
test("Login user", async () => {
// Validation du user pour bypasser vérification email
await prisma.user.update({
where: { email: "test@test.com" },
where: { email: testEmail },
data: { isEmailVerified: true },
});
const res = await request(app)
.post("/api/auth/login")
.send({
email: "test@test.com",
password: "Password123",
});
const res = await request(app).post("/api/auth/login").send({
email: testEmail,
password: "Password123",
});
expect(res.statusCode).toBe(200);
expect(res.body.token).toBeDefined();
token = res.body.token;
});
// ------------------------
// 3. PARTICIPATE GAME
// ------------------------
test("Participate to game", async () => {
const res = await request(app)
.post("/api/game/participate")
.set("Authorization", `Bearer ${token}`)
.send({ code: ticketCode });
expect([200, 201, 404, 409, 400]).toContain(res.statusCode);
console.log("Ticket in DB before participate:", ticketCode);
});
// ------------------------
// 4. MY GAINS
// ------------------------
test("Get my gains", async () => {
const res = await request(app)
.get("/api/game/my-gains")
.set("Authorization", `Bearer ${token}`);
expect(res.statusCode).toBe(200);
expect(res.body.gains).toBeDefined();
});
// ------------------------
// 3. GENERER UN TICKET
// ------------------------
test("Create a ticket for testing", async () => {
const lot = await prisma.lot.create({
data: {
name: "Lot Test",
description: "Lot pour test E2E",
value: 10,
percentage: 100
},
});
test("Create a dedicated ticket for this test", async () => {
const lot = await prisma.lot.findFirst();
expect(lot).not.toBeNull();
const ticket = await prisma.ticket.create({
data: {
code: ticketCode,
lotId: lot.id
}
data: { code: ticketCode, lotId: lot.id },
});
createdTicketId = ticket.id;
expect(ticket).toBeDefined();
expect(ticket.code).toBe(ticketCode);
});
// ------------------------
// 4. PARTICIPATE GAME
// ------------------------
test("Participate to game", async () => {
const res = await request(app)
.post("/api/game/participate")
.set("Authorization", `Bearer ${token}`)
.send({ code: ticketCode });
expect([200, 201, 404, 409, 400]).toContain(res.statusCode);
expect(res.statusCode).toBe(201);
expect(res.body.gain).toBeDefined();
});
// ------------------------
// 5. MY GAINS
// ------------------------
test("Get my gains", async () => {
const res = await request(app)
.get("/api/game/my-gains")
@@ -136,11 +99,9 @@ describe("THE TIP TOP - FULL E2E FLOW", () => {
expect(res.statusCode).toBe(200);
expect(res.body.gains).toBeDefined();
expect(res.body.gains.some((g) => g.ticketCode === ticketCode)).toBe(true);
});
// ------------------------
// 6. ADMIN STATS (CLIENT access denied)
// ------------------------
test("Admin stats - CLIENT access denied", async () => {
const res = await request(app)
.get("/api/admin/stats")
@@ -149,4 +110,4 @@ describe("THE TIP TOP - FULL E2E FLOW", () => {
expect(res.statusCode).toBe(403);
expect(res.body.message || res.body).toBeDefined();
});
});
});

View File

@@ -0,0 +1,113 @@
const request = require("supertest");
const app = require("../src/app");
const prisma = require("../src/config/prisma");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");
const mailer = require("../src/config/mailer");
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
const DAY = 86400000;
const PREFIX = "gain-";
const CODES = ["GAINTICK01", "GAINTICK02", "GAINTICK03"];
let ctx = {};
async function cleanup() {
const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } });
const ids = us.map((u) => u.id);
const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
await prisma.ticket.deleteMany({ where: { code: { in: CODES } } });
await prisma.user.deleteMany({ where: { id: { in: ids } } });
}
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
const jourJ = (d) =>
prisma.gameSettings.update({
where: { id: ctx.settingsId },
data: { startDate: new Date(Date.now() - d * DAY) },
});
beforeAll(async () => {
await cleanup();
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
ctx.settingsId = row.id;
ctx.startDateOriginale = row.startDate;
ctx.lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: ctx.lot.id } });
const h = await bcrypt.hash("Password123", 10);
ctx.client = await prisma.user.create({ data: { firstname: "Chloe", lastname: "Martin", email: PREFIX + "chloe@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
ctx.tClient = tk(ctx.client);
await jourJ(1);
});
afterAll(async () => {
await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } });
await cleanup();
await prisma.$disconnect();
});
describe("Notification du gain a chaque participation", () => {
beforeEach(() => jest.clearAllMocks());
test("le joueur recoit un email decrivant son lot", async () => {
mailer.sendMail.mockResolvedValue(true);
const res = await request(app)
.post("/api/game/participate")
.set("Authorization", "Bearer " + ctx.tClient)
.send({ code: CODES[0] })
.expect(201);
expect(res.body.emailSent).toBe(true);
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
const envoi = mailer.sendMail.mock.calls[0][0];
expect(envoi.to).toBe(ctx.client.email);
expect(envoi.subject).toContain(ctx.lot.name);
expect(envoi.html).toContain("Chloe");
expect(envoi.html).toContain(CODES[0]);
expect(envoi.html).toContain(ctx.lot.name);
});
test("un SMTP en panne n annule pas la participation", async () => {
mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible"));
const res = await request(app)
.post("/api/game/participate")
.set("Authorization", "Bearer " + ctx.tClient)
.send({ code: CODES[1] });
expect(res.status).toBe(201);
expect(res.body.emailSent).toBe(false);
// Le gain reste acquis et consultable.
const t = await prisma.ticket.findUnique({ where: { code: CODES[1] } });
expect(t.isUsed).toBe(true);
const gains = await request(app).get("/api/game/my-gains").set("Authorization", "Bearer " + ctx.tClient).expect(200);
expect(gains.body.gains.some((g) => g.ticketCode === CODES[1])).toBe(true);
});
test("aucun email si le code est refuse", async () => {
mailer.sendMail.mockResolvedValue(true);
const rejoue = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
expect(rejoue.status).toBe(409);
const inconnu = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: "INEXISTAN1" });
expect(inconnu.status).toBe(404);
await jourJ(45);
const horsDelai = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[2] });
expect(horsDelai.status).toBe(410);
await jourJ(1);
expect(mailer.sendMail).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,163 @@
const request = require("supertest");
const app = require("../src/app");
const prisma = require("../src/config/prisma");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) }));
const emails = { adm: "v-adm@test.local", emp: "v-emp@test.local", a: "v-a@test.local", b: "v-b@test.local" };
const CODES = ["VERIFTICK1", "VERIFTICK2", "VERIFTICK3", "VERIFTICK4", "VERIFTICK5"];
const DAY = 86400000;
let ctx = {};
const log = (...a) => console.log(" ", ...a);
async function cleanup() {
const us = await prisma.user.findMany({ where: { email: { in: [...Object.values(emails), "v-r1@test.local", "v-r2@test.local"] } } });
const ids = us.map((u) => u.id);
const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
await prisma.ticket.deleteMany({ where: { code: { in: CODES } } });
await prisma.user.deleteMany({ where: { id: { in: ids } } });
}
const setStart = (d) =>
prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: new Date(Date.now() - d * DAY) } });
beforeAll(async () => {
await cleanup();
ctx.settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
ctx.orig = ctx.settings.startDate;
const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: lot.id } });
const h = await bcrypt.hash("Password123", 10);
const mk = (e, r) => prisma.user.create({ data: { firstname: "V", lastname: r, email: e, password: h, role: r, isEmailVerified: true } });
ctx.adm = await mk(emails.adm, "ADMIN");
ctx.emp = await mk(emails.emp, "EMPLOYEE");
ctx.a = await mk(emails.a, "CLIENT");
ctx.b = await mk(emails.b, "CLIENT");
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
ctx.tadm = tk(ctx.adm);
ctx.temp = tk(ctx.emp);
ctx.ta = tk(ctx.a);
ctx.tb = tk(ctx.b);
});
afterAll(async () => {
await prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: ctx.orig } });
await cleanup();
await prisma.$disconnect();
});
describe("VERIFICATION DES 7 CORRECTIFS", () => {
test("#1 ADMIN peut lancer le jeu", async () => {
const futur = new Date(Date.now() + 5 * DAY).toISOString();
const r = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).send({ startDate: futur });
log("PUT game-settings ->", r.status, r.body.message);
expect(r.status).toBe(200);
const g = await request(app).get("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).expect(200);
log("GET game-settings -> participation ouverte :", g.body.gameWindow.isParticipationOpen, "(jeu pas encore commence)");
const bad = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).send({ startDate: "pas-une-date" });
log("date invalide ->", bad.status, bad.body.message);
expect(bad.status).toBe(400);
const nonAdmin = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.ta}`).send({ startDate: futur });
log("client tente de lancer le jeu ->", nonAdmin.status);
expect(nonAdmin.status).toBe(403);
});
test("#2 participation refusee apres 30 jours", async () => {
await setStart(45);
const r = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[0] });
log("jour 45 ->", r.status, r.body.message);
expect(r.status).toBe(410);
await setStart(1);
const ok = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[0] });
log("jour 1 ->", ok.status, ok.body.message || "accepte");
expect(ok.status).toBe(201);
ctx.claimId = ok.body.claim.id;
});
test("#3 expiration derivee et reversible", async () => {
await setStart(70);
const g1 = await request(app).get("/api/game/my-gains").set("Authorization", `Bearer ${ctx.ta}`).expect(200);
log("jour 70, statut affiche :", g1.body.gains.map((g) => g.claimStatus).join(","));
expect(g1.body.gains[0].claimStatus).toBe("CANCELLED");
const enBase = await prisma.claim.findUnique({ where: { id: ctx.claimId } });
log("statut REEL en base :", enBase.status, "(non ecrase)");
expect(enBase.status).toBe("PENDING");
await setStart(1);
const g2 = await request(app).get("/api/game/my-gains").set("Authorization", `Bearer ${ctx.ta}`).expect(200);
log("retour jour 1, statut :", g2.body.gains.map((g) => g.claimStatus).join(","), "(reversible)");
expect(g2.body.gains[0].claimStatus).toBe("PENDING");
});
test("#4 tirage refuse pendant le jeu", async () => {
await setStart(1);
const r = await request(app).post("/api/admin/draw").set("Authorization", `Bearer ${ctx.tadm}`);
log("tirage pendant le jeu ->", r.status, r.body.message);
expect(r.status).toBe(409);
await setStart(35);
const r2 = await request(app).post("/api/admin/draw").set("Authorization", `Bearer ${ctx.tadm}`);
log("tirage apres cloture ->", r2.status, r2.body.message, r2.body.totalEligibleParticipants ? `(${r2.body.totalEligibleParticipants} eligibles)` : "");
expect(r2.status).toBe(201);
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: [ctx.a.id, ctx.b.id] } } });
await setStart(1);
});
test("#5 admin et employe ne peuvent plus jouer", async () => {
const r1 = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.tadm}`).send({ code: CODES[1] });
const r2 = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.temp}`).send({ code: CODES[1] });
log("ADMIN ->", r1.status, r1.body.message);
log("EMPLOYE ->", r2.status, r2.body.message);
expect(r1.status).toBe(403);
expect(r2.status).toBe(403);
const t = await prisma.ticket.findUnique({ where: { code: CODES[1] } });
log("le ticket reste vierge :", t.isUsed === false);
expect(t.isUsed).toBe(false);
});
test("#6 suppression de compte ne remet pas le ticket en jeu", async () => {
await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.tb}`).send({ code: CODES[2] }).expect(201);
await request(app).delete("/api/auth/account").set("Authorization", `Bearer ${ctx.tb}`).expect(200);
const t = await prisma.ticket.findUnique({ where: { code: CODES[2] } });
log("apres suppression : isUsed =", t.isUsed, "| userId =", t.userId);
expect(t.isUsed).toBe(true);
const rejoue = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[2] });
log("tentative de rejouer le code ->", rejoue.status, rejoue.body.message);
expect(rejoue.status).toBe(409);
});
test("#7 course sur un meme code", async () => {
const h = await bcrypt.hash("Password123", 10);
const c1 = await prisma.user.create({ data: { firstname: "R", lastname: "1", email: "v-r1@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
const c2 = await prisma.user.create({ data: { firstname: "R", lastname: "2", email: "v-r2@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
const t1 = jwt.sign({ id: c1.id, role: "CLIENT" }, process.env.JWT_SECRET, { expiresIn: "1h" });
const t2 = jwt.sign({ id: c2.id, role: "CLIENT" }, process.env.JWT_SECRET, { expiresIn: "1h" });
const [r1, r2] = await Promise.all([
request(app).post("/api/game/participate").set("Authorization", `Bearer ${t1}`).send({ code: CODES[3] }),
request(app).post("/api/game/participate").set("Authorization", `Bearer ${t2}`).send({ code: CODES[3] }),
]);
const codes = [r1.status, r2.status].sort();
log("statuts obtenus :", codes.join(" + "), "(201 + 409 attendu, aucun 500)");
expect(codes).toEqual([201, 409]);
const n = await prisma.participation.count({ where: { ticket: { code: CODES[3] } } });
log("participations pour ce ticket :", n);
expect(n).toBe(1);
});
});

View File

@@ -0,0 +1,145 @@
const request = require("supertest");
const app = require("../src/app");
const prisma = require("../src/config/prisma");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) }));
const DAY = 86400000;
const PREFIX = "r2-";
const CODES = ["R2TICKET01", "R2TICKET02", "R2TICKET03", "R2TICKET04"];
let ctx = {};
const log = (...a) => console.log(" ", ...a);
const titre = (t) => console.log("\n===== " + t + " =====");
async function cleanup() {
const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } });
const ids = us.map((u) => u.id);
const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
await prisma.ticket.deleteMany({ where: { code: { in: CODES } } });
await prisma.user.deleteMany({ where: { id: { in: ids } } });
}
async function jourJ(offsetJours) {
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
const debut = new Date(Date.now() - offsetJours * DAY);
if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: debut } });
else await prisma.gameSettings.create({ data: { startDate: debut } });
}
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "2h" });
beforeAll(async () => {
await cleanup();
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
ctx.startDateOriginale = row ? row.startDate : null;
const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: lot.id } });
const h = await bcrypt.hash("Password123", 10);
ctx.admin = await prisma.user.create({ data: { firstname: "Alice", lastname: "Admin", email: PREFIX + "admin@test.local", password: h, role: "ADMIN", isEmailVerified: true } });
ctx.client = await prisma.user.create({ data: { firstname: "Zoe", lastname: "Cliente", email: PREFIX + "zoe@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
ctx.tAdmin = tk(ctx.admin);
ctx.tClient = tk(ctx.client);
});
afterAll(async () => {
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 } });
else await prisma.gameSettings.create({ data: { startDate: ctx.startDateOriginale } });
}
await cleanup();
await prisma.$disconnect();
});
describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => {
test("A. rien nest jouable avant tout lancement", async () => {
titre("A : aucune GameSettings en base");
await prisma.gameSettings.deleteMany({});
const vue = await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin).expect(200);
log("phase :", vue.body.gameWindow.phase, "| participations ouvertes :", vue.body.gameWindow.isParticipationOpen);
expect(vue.body.gameWindow.phase).toBe("NOT_CONFIGURED");
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);
const t = await prisma.ticket.findUnique({ where: { code: CODES[0] } });
log("le ticket reste vierge :", t.isUsed === false);
expect(t.isUsed).toBe(false);
const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
log("admin tente le tirage ->", tirage.status, tirage.body.message);
expect(tirage.status).toBe(409);
expect(await prisma.grandPrizeWinner.count()).toBe(0);
});
test("B. jeu programme : message juste et tirage bloque", async () => {
titre("B : lancement programme a J+7");
const futur = new Date(Date.now() + 7 * DAY).toISOString();
const r = await request(app).put("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin).send({ startDate: futur });
log("PUT game-settings ->", r.status, "| phase :", r.body.gameWindow.phase);
expect(r.body.gameWindow.phase).toBe("SCHEDULED");
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));
expect(anticipe.body.message).toMatch(/ouvrira le/);
expect(anticipe.body.message).not.toMatch(/terminée/);
const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
log("tirage avant ouverture ->", tirage.status, tirage.body.message);
expect(tirage.status).toBe(409);
expect(tirage.body.message).toMatch(/pas encore commencé/);
});
test("C. les quatre phases dun jeu configure", async () => {
titre("C : parcours des phases");
await jourJ(1);
let w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow;
const ok = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
log("jour 1 | phase", w.phase, "| participate ->", ok.status);
expect(w.phase).toBe("OPEN");
expect(ok.status).toBe(201);
ctx.claimId = ok.body.claim.id;
await jourJ(35);
w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow;
const tard = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[1] });
log("jour 35 | phase", w.phase, "| participate ->", tard.status, JSON.stringify(tard.body.message));
expect(w.phase).toBe("CLAIM_ONLY");
expect(tard.body.message).toMatch(/participation est terminée/);
const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
log("tirage apres cloture ->", tirage.status, tirage.body.message);
expect(tirage.status).toBe(201);
await jourJ(70);
w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow;
const fini = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[2] });
log("jour 70 | phase", w.phase, "| participate ->", fini.status, JSON.stringify(fini.body.message));
expect(w.phase).toBe("CLOSED");
expect(fini.body.message).toMatch(/jeu-concours est terminé/);
});
test("D. non-regression : le parcours nominal fonctionne toujours", async () => {
titre("D : verification du parcours de base");
await jourJ(2);
const g = await request(app).get("/api/game/my-gains").set("Authorization", "Bearer " + ctx.tClient).expect(200);
log("gains du client :", JSON.stringify(g.body.gains.map((x) => x.claimStatus)));
expect(g.body.gains[0].claimStatus).toBe("PENDING");
const stats = await request(app).get("/api/admin/stats").set("Authorization", "Bearer " + ctx.tAdmin).expect(200);
log("stats : phase =", stats.body.gameWindow.phase, "| participations =", stats.body.participations);
expect(stats.body.gameWindow.phase).toBe("OPEN");
});
});

View File

@@ -0,0 +1,142 @@
const request = require("supertest");
const app = require("../src/app");
const prisma = require("../src/config/prisma");
const mailer = require("../src/config/mailer");
const { passport, googleEnabled } = require("../src/config/passport");
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
const EMAIL = "google-pwd-test@test.local";
const PROFILE = {
id: "sim-google-id-123456",
displayName: "Chloe Martin",
name: { givenName: "Chloe", familyName: "Martin" },
emails: [{ value: EMAIL }],
photos: [{ value: "https://example.test/avatar.png" }],
};
async function cleanup() {
await prisma.user.deleteMany({ where: { email: EMAIL } });
}
// Rejoue le callback de la strategie Google sans sortir sur le reseau.
function verifierProfilGoogle() {
const strategy = passport._strategy("google");
return new Promise((resolve, reject) => {
strategy._verify("access-token", "refresh-token", PROFILE, (err, user) => {
if (err) reject(err);
else resolve(user);
});
});
}
beforeAll(cleanup);
afterAll(async () => {
await cleanup();
await prisma.$disconnect();
});
const decrire = googleEnabled ? describe : describe.skip;
decrire("Inscription Google et definition du mot de passe", () => {
beforeEach(() => jest.clearAllMocks());
test("un compte Google est cree sans mot de passe et recoit un lien", async () => {
mailer.sendMail.mockResolvedValue(true);
const user = await verifierProfilGoogle();
expect(user.provider).toBe("GOOGLE");
expect(user.password).toBeNull();
expect(user.isEmailVerified).toBe(true);
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
const envoi = mailer.sendMail.mock.calls[0][0];
expect(envoi.to).toBe(EMAIL);
expect(envoi.subject).toMatch(/mot de passe/i);
// Le lien doit viser le formulaire du front, pas la route POST de l API.
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
expect(envoi.html).toContain(`${frontendUrl}/definir-mot-de-passe/`);
const enBase = await prisma.user.findUnique({ where: { email: EMAIL } });
expect(enBase.resetPasswordToken).toHaveLength(64);
expect(enBase.resetPasswordExpires.getTime()).toBeGreaterThan(Date.now());
ctxToken = enBase.resetPasswordToken;
});
test("le profil signale que le compte n a pas de mot de passe", async () => {
const jwt = require("jsonwebtoken");
const u = await prisma.user.findUnique({ where: { email: EMAIL } });
const token = jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
const res = await request(app).get("/api/auth/me").set("Authorization", "Bearer " + token).expect(200);
expect(res.body.user.hasPassword).toBe(false);
expect(res.body.user.provider).toBe("GOOGLE");
// Le hash ne doit jamais sortir de l API.
expect(res.body.user.password).toBeUndefined();
});
test("la connexion par mot de passe est refusee tant qu il n en existe pas", 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 Google/);
});
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" });
expect(res.status).toBe(400);
expect(res.body.message).toMatch(/ne correspondent pas/);
const u = await prisma.user.findUnique({ where: { email: EMAIL } });
expect(u.password).toBeNull();
});
test("un mot de passe trop court est refuse", async () => {
const res = await request(app)
.post("/api/auth/reset-password/" + ctxToken)
.send({ password: "court", confirmPassword: "court" });
expect(res.status).toBe(400);
expect(res.body.message).toMatch(/8 caractères/);
});
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" })
.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);
expect(login.body.token).toBeDefined();
// Le jeton a bien ete consomme.
const u = await prisma.user.findUnique({ where: { email: EMAIL } });
expect(u.resetPasswordToken).toBeNull();
const me = await request(app).get("/api/auth/me").set("Authorization", "Bearer " + login.body.token).expect(200);
expect(me.body.user.hasPassword).toBe(true);
const change = await request(app)
.patch("/api/auth/change-password")
.set("Authorization", "Bearer " + login.body.token)
.send({ currentPassword: "MonMotDePasse1", newPassword: "EncoreUnAutre2" });
expect(change.status).toBe(200);
});
test("une seconde connexion Google ne renvoie pas de nouveau lien", async () => {
mailer.sendMail.mockResolvedValue(true);
const user = await verifierProfilGoogle();
expect(user.email).toBe(EMAIL);
expect(mailer.sendMail).not.toHaveBeenCalled();
});
});
let ctxToken;

View File

@@ -0,0 +1,66 @@
const request = require("supertest");
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(),
}));
// Comme le reste de la suite, ce fichier tourne sur la base de dev : il ne
// touche qu'au compte qu'il crée lui-même, identifié par cet email.
const testEmail = "rollback-mail-test@test.com";
const payload = {
firstname: "Camille",
lastname: "Martin",
email: testEmail,
password: "Password123",
birthdate: "1990-05-14",
gender: "Femme",
};
beforeEach(async () => {
jest.clearAllMocks();
await prisma.user.deleteMany({ where: { email: testEmail } });
});
afterAll(async () => {
await prisma.user.deleteMany({ where: { email: testEmail } });
await prisma.$disconnect();
});
describe("Inscription - echec d'envoi du code de validation", () => {
test("repond 502 et ne laisse aucun compte orphelin", async () => {
mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible"));
const res = await request(app).post("/api/auth/register").send(payload);
expect(res.statusCode).toBe(502);
const user = await prisma.user.findUnique({ where: { email: testEmail } });
expect(user).toBeNull();
});
test("l'email reste reutilisable apres un echec d'envoi", async () => {
mailer.sendMail.mockRejectedValueOnce(new Error("SMTP indisponible"));
const echec = await request(app).post("/api/auth/register").send(payload);
expect(echec.statusCode).toBe(502);
mailer.sendMail.mockResolvedValue(true);
const succes = await request(app).post("/api/auth/register").send(payload);
expect(succes.statusCode).toBe(201);
});
test("enregistre la date de naissance et le genre", async () => {
mailer.sendMail.mockResolvedValue(true);
const res = await request(app).post("/api/auth/register").send(payload);
expect(res.statusCode).toBe(201);
const user = await prisma.user.findUnique({ where: { email: testEmail } });
expect(user.gender).toBe("Femme");
expect(user.birthdate.toISOString()).toBe("1990-05-14T00:00:00.000Z");
});
});

View File

@@ -1,31 +0,0 @@
const prisma = require("../src/config/prisma");
beforeAll(async () => {
await prisma.claim.deleteMany();
await prisma.participation.deleteMany();
await prisma.ticket.deleteMany();
await prisma.user.deleteMany();
// Création de lots
await prisma.lot.createMany({
data: [
{ name: "Infuseur", value: 10, percentage: 60, description: "Infuseur à thé" },
{ name: "Thé Détox", value: 15, percentage: 20, description: "Thé détox bio" },
{ name: "Thé Signature", value: 20, percentage: 10, description: "Thé signature" },
{ name: "Coffret 39€", value: 39, percentage: 6, description: "Coffret découverte" },
{ name: "Coffret 69€", value: 69, percentage: 4, description: "Coffret premium" },
]
});
// Création d'un ticket
await prisma.ticket.create({
data: {
code: "ABC123",
lotId: 1
}
});
});
afterAll(async () => {
await prisma.$disconnect();
});

View File

@@ -38,6 +38,7 @@ services:
JWT_SECRET: dev_secret_change_me
APP_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr
API_PUBLIC_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr
FRONTEND_URL: https://dev.dsp5-archi-o24a-4-5-g4.fr
command: >
sh -c "

View File

@@ -38,6 +38,7 @@ services:
JWT_SECRET: preprod_secret_change_me
APP_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr
API_PUBLIC_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr
FRONTEND_URL: https://preprod.dsp5-archi-o24a-4-5-g4.fr
command: >
sh -c "

View File

@@ -38,6 +38,7 @@ 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
command: >
sh -c "

View File

@@ -8,7 +8,10 @@ RUN npm ci
COPY . .
ARG VITE_API_URL
ARG VITE_GA_ID
ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_GA_ID=${VITE_GA_ID}
RUN npm run build

View File

@@ -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**.

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,8 @@ import ProtectedRoute from './components/ProtectedRoute.jsx'
import CookieBanner from './components/CookieBanner.jsx'
import Connexion from './pages/Connexion'
import Inscription from './pages/Inscription'
import AuthCallback from './pages/AuthCallback'
import DefinirMotDePasse from './pages/DefinirMotDePasse'
import Accueil from './pages/Accueil'
import ReglesJeu from './pages/ReglesJeu'
import MesGains from './pages/MesGains'
@@ -29,6 +31,8 @@ export default function App() {
<Route path="/" element={<Navigate to="/accueil" replace />} />
<Route path="/connexion" element={<Connexion />} />
<Route path="/inscription" element={<Inscription />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/definir-mot-de-passe/:token" element={<DefinirMotDePasse />} />
{/* Pages publiques */}
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />

View File

@@ -1,40 +1,78 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { initAnalytics, trackPageView } from '../services/analytics'
export default function CookieBanner() {
const [visible, setVisible] = useState(() => !localStorage.getItem('cookieConsent'))
const location = useLocation()
if (!visible) return null
const [consent, setConsent] = useState(() =>
localStorage.getItem('cookieConsent')
)
const visible = consent === null
// Si le consentement avait déjà été donné lors d'une précédente visite,
// Analytics est initialisé au chargement de l'application.
useEffect(() => {
if (consent !== 'accepted') return
initAnalytics()
trackPageView(location.pathname + location.search)
}, [consent, location.pathname, location.search])
const handleChoice = (choice) => {
localStorage.setItem('cookieConsent', choice)
setVisible(false)
setConsent(choice)
if (choice === 'accepted') {
initAnalytics()
trackPageView(location.pathname + location.search)
}
}
if (!visible) return null
return (
<div className="fixed bottom-0 left-0 right-0 z-50 px-4 py-4 bg-white border-t border-gray-200 shadow-lg">
<div className="max-w-5xl mx-auto flex flex-col sm:flex-row items-start sm:items-center gap-4">
<div className="flex-1">
<p className="text-sm font-semibold text-gray-800 mb-1">Ce site utilise des cookies</p>
<p className="text-sm font-semibold text-gray-800 mb-1">
Gestion des cookies
</p>
<p className="text-xs text-gray-500 leading-relaxed">
Nous utilisons des cookies essentiels au fonctionnement du site. En continuant votre navigation, vous acceptez leur utilisation conformément à notre{' '}
<Link to="/confidentialite" className="text-[#006B6B] hover:underline">politique de confidentialité</Link>.
Les cookies strictement nécessaires au fonctionnement du site sont
utilisés automatiquement. Avec votre accord, nous utilisons également
Google Analytics afin de mesurer l'audience et améliorer le site. Vous
pouvez accepter ou refuser ces cookies statistiques. Consultez notre{' '}
<Link
to="/confidentialite"
className="text-[#006B6B] hover:underline"
>
politique de confidentialité
</Link>.
</p>
</div>
<div className="flex gap-2 flex-shrink-0 w-full sm:w-auto">
<button
type="button"
onClick={() => handleChoice('refused')}
className="flex-1 sm:flex-none border border-gray-300 text-gray-600 hover:bg-gray-50 text-sm font-medium px-4 py-2.5 rounded-lg transition-colors"
>
Refuser
</button>
<button
type="button"
onClick={() => handleChoice('accepted')}
className="flex-1 sm:flex-none bg-[#006B6B] hover:bg-[#005959] text-white text-sm font-medium px-4 py-2.5 rounded-lg transition-colors"
>
Accepter
</button>
</div>
</div>
</div>
)

View File

@@ -9,7 +9,7 @@ export default function Header() {
const navigate = useNavigate()
const { user, logout } = useAuth()
const isLoggedIn = !!user
const logoContent = <img src={logo} alt="Logo de Thé Tip Top" className="h-10 sm:h-12 w-auto" />
const logoContent = <img src={logo} alt="Logo de Thé Tip Top" className="h-7 sm:h-8 md:h-9 lg:h-12 w-auto" />
const handleLogout = () => {
logout()
@@ -40,12 +40,12 @@ export default function Header() {
)}
{/* Nav desktop */}
<nav className="hidden md:flex items-center gap-6">
<nav className="hidden md:flex items-center gap-4 lg:gap-6">
{navLinks.map((link) => (
<Link
key={link.to}
to={link.to}
className={`font-medium transition-colors ${
className={`font-medium whitespace-nowrap transition-colors ${
pathname === link.to ? 'text-[#006B6B]' : 'text-gray-700 hover:text-[#006B6B]'
}`}
>
@@ -59,21 +59,21 @@ export default function Header() {
<div className="w-8 h-8 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-xs font-bold">
{initials}
</div>
<span>{user.firstname}</span>
<span className="whitespace-nowrap">{user.firstname}</span>
</Link>
<button
onClick={handleLogout}
className="text-sm text-gray-400 hover:text-red-500 transition-colors font-medium"
className="text-sm text-gray-500 hover:text-red-500 transition-colors font-medium whitespace-nowrap"
>
Déconnexion
</button>
</div>
) : (
<div className="flex items-center gap-2">
<Link to="/connexion" className="bg-[#006B6B] text-white px-4 py-2 rounded-lg hover:bg-[#005a5a] transition-colors text-sm">
<Link to="/connexion" className="bg-[#006B6B] border border-transparent text-white px-4 py-2 rounded-lg hover:bg-[#005a5a] transition-colors text-sm whitespace-nowrap">
Connexion
</Link>
<Link to="/inscription" className="border border-[#006B6B] text-[#006B6B] px-4 py-2 rounded-lg hover:bg-[#006B6B] hover:text-white transition-colors text-sm">
<Link to="/inscription" className="border border-[#006B6B] text-[#006B6B] px-4 py-2 rounded-lg hover:bg-[#006B6B] hover:text-white transition-colors text-sm whitespace-nowrap">
Inscription
</Link>
</div>

View File

@@ -6,6 +6,7 @@ export default function UserNav({ active }) {
key: 'code',
to: '/saisir-code-unique',
label: 'Saisir un code',
shortLabel: 'Code',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z" />
@@ -16,6 +17,7 @@ export default function UserNav({ active }) {
key: 'gains',
to: '/mes-gains',
label: 'Mes gains',
shortLabel: 'Mes gains',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 12V22H4V12M22 7H2v5h20V7zM12 22V7M12 7H7.5a2.5 2.5 0 010-5C11 2 12 7 12 7zM12 7h4.5a2.5 2.5 0 000-5C13 2 12 7 12 7z" />
@@ -26,6 +28,7 @@ export default function UserNav({ active }) {
key: 'profile',
to: '/profile',
label: 'Profil',
shortLabel: 'Profil',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
@@ -43,12 +46,12 @@ export default function UserNav({ active }) {
className={`flex-1 flex items-center justify-center gap-1.5 py-3 text-xs sm:text-sm font-medium transition-colors border-b-2 ${
active === tab.key
? 'text-[#006B6B] border-[#006B6B]'
: 'text-gray-400 border-transparent hover:text-gray-600'
: 'text-gray-500 border-transparent hover:text-gray-600'
}`}
>
{tab.icon}
<span className="hidden sm:inline">{tab.label}</span>
<span className="sm:hidden">{tab.label.split(' ')[0]}</span>
<span className="sm:hidden whitespace-nowrap">{tab.shortLabel}</span>
</Link>
))}
</div>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi } from '../services/admin'
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi } from '../services/admin'
import { searchTicketApi, deliverClaimApi } from '../services/employee'
import { useHandleAuthError } from '../hooks/useHandleAuthError'
@@ -8,11 +8,37 @@ function StatCard({ label, value, sub }) {
<div className="bg-white rounded-xl shadow p-4 text-center">
<p className="text-2xl sm:text-3xl font-bold text-[#006B6B]">{value ?? '—'}</p>
<p className="text-xs sm:text-sm text-gray-500 mt-1">{label}</p>
{sub && <p className="text-xs text-gray-400 mt-0.5">{sub}</p>}
{sub && <p className="text-xs text-gray-500 mt-0.5">{sub}</p>}
</div>
)
}
// Les cinq états renvoyés par l'API, chacun avec sa couleur : « programmé »
// ne doit pas être confondu avec « retraits uniquement ».
const PHASE_BADGES = {
NOT_CONFIGURED: { label: 'Non lancé', className: 'bg-amber-100 text-amber-700' },
SCHEDULED: { label: 'Programmé', className: 'bg-indigo-100 text-indigo-700' },
OPEN: { label: 'Participations ouvertes', className: 'bg-green-100 text-green-700' },
CLAIM_ONLY: { label: 'Retraits uniquement', className: 'bg-blue-100 text-blue-700' },
CLOSED: { label: 'Terminé', className: 'bg-red-100 text-red-700' },
}
const PHASE_PANELS = {
NOT_CONFIGURED: 'bg-amber-50 border border-amber-200',
SCHEDULED: 'bg-indigo-50 border border-indigo-200',
OPEN: 'bg-white',
CLAIM_ONLY: 'bg-white',
CLOSED: 'bg-red-50 border border-red-200',
}
const PHASE_TITLES = {
NOT_CONFIGURED: 'Jeu-concours non lancé',
SCHEDULED: 'Jeu-concours programmé',
OPEN: 'Jeu-concours en cours',
CLAIM_ONLY: 'Participations closes, retraits en cours',
CLOSED: 'Jeu-concours terminé',
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: '2-digit', month: 'long', year: 'numeric',
@@ -31,6 +57,12 @@ export default function Admin() {
const [error, setError] = useState('')
const [showConfirm, setShowConfirm] = useState(false)
// Lancement du jeu-concours
const [startDateInput, setStartDateInput] = useState('')
const [settingsSaving, setSettingsSaving] = useState(false)
const [settingsError, setSettingsError] = useState('')
const [settingsOk, setSettingsOk] = useState('')
// Remise de lot
const [ticketCode, setTicketCode] = useState('')
const [ticketResult, setTicketResult] = useState(null)
@@ -56,7 +88,26 @@ export default function Admin() {
if (!handleAuthError(e)) setError(e.message)
})
.finally(() => setLoading(false))
}, [handleAuthError])
}, [handleAuthError])
const handleSaveGameSettings = async (e) => {
e.preventDefault()
setSettingsError('')
setSettingsOk('')
setSettingsSaving(true)
try {
// L'input datetime-local renvoie une heure locale sans fuseau : on la
// convertit en ISO pour que le serveur reçoive un instant sans ambiguïté.
const data = await setGameSettingsApi(new Date(startDateInput).toISOString())
setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev))
setSettingsOk(data.message)
setStartDateInput('')
} catch (err) {
if (!handleAuthError(err)) setSettingsError(err.message)
} finally {
setSettingsSaving(false)
}
}
const handleDraw = async () => {
setShowConfirm(false)
@@ -94,7 +145,10 @@ export default function Admin() {
const csv = [header, ...rows]
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';'))
.join('\n')
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })
// Le BOM en tete du fichier est indispensable : sans lui, Excel ouvre le
// CSV en ANSI et casse tous les accents. Ne pas le supprimer.
// eslint-disable-next-line no-irregular-whitespace
const blob = new Blob([`${csv}`], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
@@ -145,7 +199,7 @@ export default function Admin() {
if (loading) return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<p className="text-gray-400">Chargement...</p>
<p className="text-gray-500">Chargement...</p>
</div>
)
@@ -164,21 +218,61 @@ export default function Admin() {
<h1 className="text-xl sm:text-2xl font-bold text-gray-800">Tableau de bord Admin</h1>
{/* Fenêtre du jeu-concours */}
{stats?.gameWindow?.configured && (
<div className={`rounded-xl shadow p-4 sm:p-6 ${stats.gameWindow.isOpen ? 'bg-white' : 'bg-red-50 border border-red-200'}`}>
{stats?.gameWindow && (
<div className={`rounded-xl shadow p-4 sm:p-6 ${PHASE_PANELS[stats.gameWindow.phase] ?? 'bg-white'}`}>
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<p className="text-sm font-semibold text-gray-800">
Jeu-concours {stats.gameWindow.isOpen ? 'en cours' : 'terminé'}
</p>
<p className="text-xs text-gray-500 mt-1">
Début : {formatDate(stats.gameWindow.startDate)} — Fin des participations et réclamations : {formatDate(stats.gameWindow.claimDeadline)}
{PHASE_TITLES[stats.gameWindow.phase] ?? 'Jeu-concours'}
</p>
{stats.gameWindow.configured ? (
<p className="text-xs text-gray-500 mt-1">
Début : {formatDate(stats.gameWindow.startDate)}
<br />
Fin des participations : {formatDate(stats.gameWindow.participationDeadline)}
<br />
Fin des retraits de lots : {formatDate(stats.gameWindow.claimDeadline)}
</p>
) : (
<p className="text-xs text-gray-600 mt-1">
Choisissez une date de début pour ouvrir le jeu-concours.
</p>
)}
</div>
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${stats.gameWindow.isOpen ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{stats.gameWindow.isOpen ? 'Ouvert' : 'Fermé'}
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${PHASE_BADGES[stats.gameWindow.phase]?.className ?? 'bg-gray-100 text-gray-700'}`}>
{PHASE_BADGES[stats.gameWindow.phase]?.label ?? 'État inconnu'}
</span>
</div>
<form onSubmit={handleSaveGameSettings} className="mt-4 pt-4 border-t border-gray-200 flex flex-wrap items-end gap-3">
<div className="flex-1 min-w-[220px]">
<label htmlFor="game-start" className="block text-xs font-medium text-gray-700 mb-1">
{stats.gameWindow.configured ? 'Corriger la date de début' : 'Date de début du jeu-concours'}
</label>
<input
type="datetime-local"
id="game-start"
value={startDateInput}
onChange={(e) => setStartDateInput(e.target.value)}
required
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
<button
type="submit"
disabled={settingsSaving || !startDateInput}
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
>
{settingsSaving ? 'Enregistrement…' : stats.gameWindow.configured ? 'Mettre à jour' : 'Lancer le jeu'}
</button>
</form>
<p className="text-xs text-gray-500 mt-2">
Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.
</p>
{settingsError && <p className="text-red-500 text-xs mt-2">{settingsError}</p>}
{settingsOk && <p className="text-green-600 text-xs mt-2">{settingsOk}</p>}
</div>
)}
@@ -302,7 +396,7 @@ export default function Admin() {
<tbody>
{emailingContacts.length === 0 && (
<tr>
<td colSpan={3} className="py-6 text-center text-gray-400 text-sm">
<td colSpan={3} className="py-6 text-center text-gray-500 text-sm">
Aucun contact n'a accepté l'emailing pour le moment
</td>
</tr>
@@ -337,7 +431,7 @@ export default function Admin() {
onChange={(e) => setTicketCode(e.target.value.toUpperCase())}
placeholder="Code ticket (10 caractères)"
maxLength={10}
className="flex-1 border border-gray-200 rounded-lg px-4 py-2.5 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]"
className="flex-1 min-w-0 border border-gray-200 rounded-lg px-4 py-2.5 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
<button
type="submit"
@@ -366,7 +460,7 @@ export default function Admin() {
<div className="px-4 py-3 flex items-start justify-between gap-3 border-b border-gray-100">
<div>
<p className="font-semibold text-gray-900 text-sm">{ticketResult.lot.name}</p>
<p className="text-gray-400 text-xs mt-0.5">{ticketResult.lot.description}</p>
<p className="text-gray-500 text-xs mt-0.5">{ticketResult.lot.description}</p>
{ticketResult.lot.value && <p className="text-[#006B6B] text-xs font-semibold mt-1">{ticketResult.lot.value} €</p>}
</div>
<span className={`flex-shrink-0 text-xs font-semibold px-2.5 py-1 rounded-full ${
@@ -436,10 +530,17 @@ export default function Admin() {
</div>
</div>
{winner?.winner?.createdAt && (
<p className="text-xs text-gray-400 text-right">
<p className="text-xs text-gray-500 text-right">
Tirage effectué le {formatDate(winner.winner.createdAt)}
</p>
)}
{/* Signalé uniquement au retour du tirage : le GET du gagnant ne
renvoie pas emailSent, et l'absence de la clé ne vaut pas échec. */}
{winner?.emailSent === false && (
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
L&apos;email de notification n&apos;a pas pu être envoyé au gagnant. Merci de le contacter directement à l&apos;adresse ci-dessus.
</p>
)}
</div>
) : (
/* Pas encore de tirage */
@@ -474,7 +575,7 @@ export default function Admin() {
{/* Liste participants */}
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">
Participants <span className="text-gray-400 font-normal text-sm">({users.length})</span>
Participants <span className="text-gray-500 font-normal text-sm">({users.length})</span>
</h2>
<div className="overflow-x-auto -mx-4 sm:mx-0">
<table className="w-full text-sm min-w-[400px]">
@@ -488,7 +589,7 @@ export default function Admin() {
<tbody>
{users.length === 0 && (
<tr>
<td colSpan={3} className="py-6 text-center text-gray-400 text-sm">
<td colSpan={3} className="py-6 text-center text-gray-500 text-sm">
Aucun participant pour le moment
</td>
</tr>

View File

@@ -0,0 +1,76 @@
import { useEffect, useRef, useState } from 'react'
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
// Point d'arrivée après un aller-retour OAuth : le backend nous renvoie ici
// avec le JWT et le profil encodés dans l'URL.
export default function AuthCallback() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { login } = useAuth()
const errorParam = searchParams.get('error')
const [error, setError] = useState(errorParam || '')
// StrictMode monte deux fois en dev : on ne traite le token qu'une seule fois.
const handled = useRef(false)
useEffect(() => {
if (handled.current) return
handled.current = true
if (errorParam) return
const token = searchParams.get('token')
const encodedUser = searchParams.get('user')
if (!token || !encodedUser) {
setTimeout(() => {
setError('Réponse de connexion incomplète.')
}, 0)
return
}
try {
const user = JSON.parse(atob(encodedUser))
login(user, token)
if (user.role === 'ADMIN') {
navigate('/admin', { replace: true })
} else if (user.role === 'EMPLOYEE') {
navigate('/employe', { replace: true })
} else {
navigate('/mes-gains', { replace: true })
}
} catch {
setTimeout(() => {
setError('Réponse de connexion illisible.')
}, 0)
}
}, [searchParams, errorParam, login, navigate])
if (error) {
return (
<div className="min-h-screen bg-white flex flex-col justify-center items-center px-8 max-w-sm mx-auto text-center">
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
{error}
</div>
<Link
to="/connexion"
className="text-sm text-[#006B6B] font-medium hover:underline"
>
Retour à la connexion
</Link>
</div>
)
}
return (
<div className="min-h-screen bg-white flex flex-col justify-center items-center px-8 max-w-sm mx-auto text-center">
<p className="text-gray-500 text-sm">
Connexion en cours…
</p>
</div>
)
}

View File

@@ -15,6 +15,14 @@ export default function Connexion() {
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
// Navigation pleine page (et non fetch) : le flux OAuth passe par des
// redirections vers Google puis retour sur /auth/callback.
const API_URL = import.meta.env.VITE_API_URL || ''
const handleGoogleLogin = () => {
window.location.href = `${API_URL}/api/auth/google`
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
@@ -66,7 +74,7 @@ export default function Connexion() {
onChange={handleChange}
required
placeholder="Votre email"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
@@ -83,12 +91,12 @@ export default function Connexion() {
onChange={handleChange}
required
placeholder="Votre mot de passe"
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600"
aria-label="Afficher/masquer le mot de passe"
>
<div className="w-6 h-6 rounded-full border-2 border-gray-400 flex items-center justify-center">
@@ -99,7 +107,7 @@ export default function Connexion() {
</button>
</div>
<div className="text-right mt-1">
<button type="button" className="text-xs text-gray-400 hover:text-[#006B6B] hover:underline">
<button type="button" className="text-xs text-gray-500 hover:text-[#006B6B] hover:underline">
Mot de passe oublié ?
</button>
</div>
@@ -123,20 +131,27 @@ export default function Connexion() {
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={handleGoogleLogin}
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">
G
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path>
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path>
</svg>
Google
</button>
<button
type="button"
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
disabled
title="Connexion Facebook bientôt disponible"
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-500 cursor-not-allowed opacity-60"
>
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">
f
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="12" fill="#1877F2" />
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />
</svg>
Facebook
</button>
</div>

View File

@@ -3,152 +3,43 @@ import imgContact from '../assets/img/img-contact.jpg'
export default function Contact() {
return (
<main className="bg-[#F0F0F0] px-4 sm:px-6 py-10 sm:py-16">
<div className="max-w-6xl mx-auto">
<div className="max-w-6xl mx-auto grid md:grid-cols-2 gap-6 sm:gap-8">
<div className="grid md:grid-cols-2 gap-6 sm:gap-8 mb-8">
{/* Informations */}
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
Contact
</h1>
<p className="text-gray-600 leading-relaxed mb-6 sm:mb-8 text-sm sm:text-base">
Une question concernant le jeu-concours, votre participation ou votre lot ?
Notre équipe ThéTipTop est à votre écoute.
</p>
<div className="space-y-5 text-gray-700">
<div>
<h2 className="font-semibold text-[#006B6B] mb-1">
Adresse
</h2>
<p className="text-sm sm:text-base">
ThéTipTop
<br />
18 rue Léon Frot
<br />
75011 Paris, France
</p>
</div>
<div>
<h2 className="font-semibold text-[#006B6B] mb-1">
Email
</h2>
<a
href="mailto:contact@thetiptop.fr"
className="text-sm sm:text-base hover:text-[#006B6B]"
>
contact@thetiptop.fr
</a>
</div>
<div>
<h2 className="font-semibold text-[#006B6B] mb-1">
Téléphone
</h2>
<a
href="tel:+33184802034"
className="text-sm sm:text-base hover:text-[#006B6B]"
>
01 84 80 20 34
</a>
</div>
</div>
</section>
{/* Image */}
<section className="rounded-2xl shadow overflow-hidden min-h-80">
<img
src={imgContact}
alt="Une personne savourant un thé"
className="w-full h-full object-cover"
/>
</section>
</div>
{/* Formulaire Emmanuel intégré au design Alexis */}
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10 max-w-4xl mx-auto">
<h2 className="text-xl sm:text-2xl font-bold text-[#006B6B] mb-4 sm:mb-6">
Envoyez-nous un message
</h2>
<form className="space-y-4 sm:space-y-5">
{/* Informations */}
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
Contact
</h1>
<p className="text-gray-600 leading-relaxed mb-6 sm:mb-8 text-sm sm:text-base">
Une question concernant le jeu-concours, votre participation ou votre lot ?
Notre équipe ThéTipTop est à votre écoute.
</p>
<div className="space-y-5 text-gray-700">
<div>
<label
htmlFor="contact-name"
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
>
Nom
</label>
<input
type="text"
id="contact-name"
placeholder="Votre nom"
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B]"
/>
<h2 className="font-semibold text-[#006B6B] mb-1">Adresse</h2>
<p className="text-sm sm:text-base">ThéTipTop<br />18 rue Léon Frot<br />75011 Paris, France</p>
</div>
<div>
<label
htmlFor="contact-email"
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
>
Email
</label>
<input
type="email"
id="contact-email"
placeholder="Votre email"
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B]"
/>
<h2 className="font-semibold text-[#006B6B] mb-1">Email</h2>
<a href="mailto:contact@thetiptop.fr" className="text-sm sm:text-base hover:text-[#006B6B]">contact@thetiptop.fr</a>
</div>
<div>
<label
htmlFor="contact-subject"
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
>
Sujet
</label>
<input
type="text"
id="contact-subject"
placeholder="Sujet de votre demande"
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B]"
/>
<h2 className="font-semibold text-[#006B6B] mb-1">Téléphone</h2>
<a href="tel:+33184802034" className="text-sm sm:text-base hover:text-[#006B6B]">01 84 80 20 34</a>
</div>
</div>
</section>
<div>
<label
htmlFor="contact-message"
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
>
Message
</label>
<textarea
id="contact-message"
rows="4"
placeholder="Votre message..."
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B] resize-none"
/>
</div>
<button
type="submit"
className="w-full bg-[#006B6B] hover:bg-[#005858] text-white font-semibold py-2.5 sm:py-3 rounded-xl transition text-sm sm:text-base"
>
Envoyer le message
</button>
</form>
{/* Image */}
<section className="rounded-2xl shadow overflow-hidden h-64 sm:h-80 md:h-[468px] md:self-start">
<img
src={imgContact}
alt="Une personne savourant un thé"
className="w-full h-full object-cover"
/>
</section>
</div>
</main>
)
}
}

View File

@@ -0,0 +1,133 @@
import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { resetPasswordApi } from '../services/auth'
// Sert les deux parcours qui passent par un jeton envoyé par email :
// la définition initiale du mot de passe après une inscription Google,
// et la réinitialisation classique depuis « mot de passe oublié ».
export default function DefinirMotDePasse() {
const { token } = useParams()
const navigate = useNavigate()
const [form, setForm] = useState({ password: '', confirmPassword: '' })
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [loading, setLoading] = useState(false)
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (form.password.length < 8) {
setError('Le mot de passe doit contenir au moins 8 caractères.')
return
}
if (form.password !== form.confirmPassword) {
setError('Les deux mots de passe ne correspondent pas.')
return
}
setLoading(true)
try {
await resetPasswordApi({ token, ...form })
setSuccess('Mot de passe enregistré. Vous pouvez maintenant vous connecter avec votre email.')
setTimeout(() => navigate('/connexion'), 2500)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
if (success) {
return (
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto text-center">
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm">
{success}
</div>
<Link to="/connexion" className="text-sm text-[#006B6B] font-medium hover:underline">
Aller à la connexion
</Link>
</div>
)
}
return (
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
<div className="mb-8">
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Mot de passe</h1>
<p className="text-gray-500 mt-1 text-sm">
Choisissez un mot de passe pour accéder à votre compte sans passer par Google.
</p>
</div>
{error && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-800 mb-1">
Nouveau mot de passe <span className="text-gray-800">*</span>
</label>
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
value={form.password}
onChange={handleChange}
required
minLength={8}
placeholder="8 caractères minimum"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-800 mb-1">
Confirmer le mot de passe <span className="text-gray-800">*</span>
</label>
<input
type={showPassword ? 'text' : 'password'}
id="confirmPassword"
name="confirmPassword"
value={form.confirmPassword}
onChange={handleChange}
required
placeholder="Saisissez-le à nouveau"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showPassword}
onChange={(e) => setShowPassword(e.target.checked)}
className="w-5 h-5 accent-[#006B6B]"
/>
<span className="text-xs text-gray-500">Afficher les mots de passe</span>
</label>
<button
type="submit"
disabled={loading}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
>
{loading ? 'Enregistrement…' : 'Enregistrer mon mot de passe'}
</button>
</form>
<p className="text-center text-sm text-gray-500 mt-8">
<Link to="/connexion" className="text-gray-700 font-medium hover:underline">
Retour à la connexion
</Link>
</p>
</div>
)
}

View File

@@ -94,7 +94,7 @@ export default function Employe() {
required
placeholder="Code ticket (10 caractères)"
maxLength={10}
className="flex-1 border border-gray-300 rounded-lg px-4 py-3 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]"
className="flex-1 min-w-0 border border-gray-300 rounded-lg px-4 py-3 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
<button
type="submit"
@@ -142,7 +142,7 @@ export default function Employe() {
{/* Infos client */}
<div className="px-5 py-4 space-y-2 border-b border-gray-100">
<p className="text-xs text-gray-400 font-medium uppercase tracking-wider mb-2">Client</p>
<p className="text-xs text-gray-500 font-medium uppercase tracking-wider mb-2">Client</p>
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-sm font-bold flex-shrink-0">
{(result.client.firstname?.[0] ?? '?').toUpperCase()}{(result.client.lastname?.[0] ?? '?').toUpperCase()}
@@ -158,7 +158,7 @@ export default function Employe() {
{/* Infos ticket */}
<div className="px-5 py-4 space-y-1 border-b border-gray-100">
<p className="text-xs text-gray-400 font-medium uppercase tracking-wider mb-2">Ticket</p>
<p className="text-xs text-gray-500 font-medium uppercase tracking-wider mb-2">Ticket</p>
<p className="text-sm text-gray-700">
<span className="font-medium">Code :</span>{' '}
<span className="font-mono">{result.ticket.code}</span>

View File

@@ -3,6 +3,13 @@ import { Link, useNavigate } from 'react-router-dom'
import { registerApi, verifyCodeApi } from '../services/auth'
import { useAuth } from '../context/AuthContext'
// Une naissance ne peut pas être dans le futur : borne haute du sélecteur.
const AUJOURD_HUI = new Date().toISOString().split('T')[0]
// Valeurs alignées sur le tableau de bord admin, qui compte exactement
// "Homme" et "Femme" ; toute autre valeur retombe dans la catégorie "autre".
const GENRES = ['Femme', 'Homme', 'Autre']
export default function Inscription() {
const navigate = useNavigate()
const { login } = useAuth()
@@ -10,8 +17,18 @@ export default function Inscription() {
// Étape : 'form' | 'verify'
const [step, setStep] = useState('form')
const [email, setEmail] = useState('')
const [form, setForm] = useState({ email: '', lastName: '', firstName: '', password: '' })
const [form, setForm] = useState({
email: '',
lastName: '',
firstName: '',
password: '',
birthdate: '',
gender: '',
})
const [acceptedTerms, setAcceptedTerms] = useState(false)
// Consentement marketing : distinct des CGU et décoché par défaut, il doit
// rester un choix libre et non une condition d'inscription.
const [marketingOptIn, setMarketingOptIn] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
@@ -24,6 +41,11 @@ export default function Inscription() {
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
// Même route que la connexion : Google crée le compte à la première venue.
const handleGoogleSignup = () => {
window.location.href = '/api/auth/google'
}
// Étape 1 — Inscription
const handleSubmit = async (e) => {
e.preventDefault()
@@ -35,6 +57,9 @@ export default function Inscription() {
firstname: form.firstName,
lastname: form.lastName,
password: form.password,
birthdate: form.birthdate,
gender: form.gender,
marketingOptIn,
})
setEmail(form.email)
setStep('verify')
@@ -129,7 +154,7 @@ export default function Inscription() {
</button>
</form>
<p className="text-center text-xs text-gray-400 mt-6">
<p className="text-center text-xs text-gray-500 mt-6">
Le code expire dans 15 minutes.{' '}
<button
type="button"
@@ -161,28 +186,45 @@ export default function Inscription() {
<div>
<label htmlFor="reg-email" className="block text-sm font-medium text-gray-800 mb-1">Adresse email <span className="text-gray-800">*</span></label>
<input type="email" id="reg-email" name="email" value={form.email} onChange={handleChange} required placeholder="Votre email"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
</div>
<div>
<label htmlFor="reg-lastName" className="block text-sm font-medium text-gray-800 mb-1">Nom <span className="text-gray-800">*</span></label>
<input type="text" id="reg-lastName" name="lastName" value={form.lastName} onChange={handleChange} required placeholder="Votre nom"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
</div>
<div>
<label htmlFor="reg-firstName" className="block text-sm font-medium text-gray-800 mb-1">Prénom <span className="text-gray-800">*</span></label>
<input type="text" id="reg-firstName" name="firstName" value={form.firstName} onChange={handleChange} required placeholder="Votre prénom"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
</div>
<div>
<label htmlFor="reg-birthdate" className="block text-sm font-medium text-gray-800 mb-1">Date de naissance</label>
<input type="date" id="reg-birthdate" name="birthdate" value={form.birthdate} onChange={handleChange} max={AUJOURD_HUI}
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
</div>
<div>
<label htmlFor="reg-gender" className="block text-sm font-medium text-gray-800 mb-1">Genre</label>
<select id="reg-gender" name="gender" value={form.gender} onChange={handleChange}
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]">
<option value="">Non précisé</option>
{GENRES.map((genre) => (
<option key={genre} value={genre}>{genre}</option>
))}
</select>
</div>
<div>
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
<div className="relative">
<input type={showPassword ? 'text' : 'password'} id="reg-password" name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
<button type="button" onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" aria-label="Afficher/masquer">
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600" aria-label="Afficher/masquer">
<div className="w-6 h-6 rounded-full border-2 border-gray-400 flex items-center justify-center">
{showPassword && <div className="w-1.5 h-1.5 rounded-full bg-gray-400" />}
</div>
@@ -192,7 +234,7 @@ export default function Inscription() {
<label className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" checked={acceptedTerms} onChange={(e) => setAcceptedTerms(e.target.checked)} required
className="mt-0.5 w-4 h-4 accent-[#006B6B] flex-shrink-0" />
className="mt-0.5 w-5 h-5 accent-[#006B6B] flex-shrink-0" />
<span className="text-xs text-gray-500 leading-relaxed">
J'accepte les{' '}
<Link to="/cgu" className="text-[#006B6B] hover:underline font-medium">conditions d'utilisation</Link>{' '}
@@ -201,6 +243,18 @@ export default function Inscription() {
</span>
</label>
<label className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" name="marketingOptIn" checked={marketingOptIn}
onChange={(e) => setMarketingOptIn(e.target.checked)}
className="mt-0.5 w-5 h-5 accent-[#006B6B] flex-shrink-0" />
<span className="text-xs text-gray-500 leading-relaxed">
J'accepte de recevoir les offres et actualités de Thé Tip Top par email.
<span className="block text-gray-500 mt-0.5">
Facultatif — vous pouvez vous désinscrire à tout moment.
</span>
</span>
</label>
<button type="submit" disabled={loading || !acceptedTerms}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors">
{loading ? 'Inscription…' : "S'inscrire"}
@@ -214,12 +268,20 @@ export default function Inscription() {
</div>
<div className="grid grid-cols-2 gap-3">
<button type="button" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">G</div>
<button type="button" onClick={handleGoogleSignup} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path>
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path>
</svg>
Google
</button>
<button type="button" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">f</div>
<button type="button" disabled title="Inscription Facebook bientôt disponible" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-500 cursor-not-allowed opacity-60">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="12" fill="#1877F2" />
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />
</svg>
Facebook
</button>
</div>

View File

@@ -83,7 +83,7 @@ export default function MesGains() {
</button>
</div>
{loading && <p className="text-center text-gray-400 text-sm mt-8">Chargement…</p>}
{loading && <p className="text-center text-gray-500 text-sm mt-8">Chargement…</p>}
{error && (
<div className="px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
@@ -93,8 +93,8 @@ export default function MesGains() {
{!loading && !error && gains.length === 0 && (
<div className="text-center mt-12">
<p className="text-gray-400 text-sm">Aucun gain pour le moment.</p>
<p className="text-gray-400 text-xs mt-1">Saisissez un code pour participer !</p>
<p className="text-gray-500 text-sm">Aucun gain pour le moment.</p>
<p className="text-gray-500 text-xs mt-1">Saisissez un code pour participer !</p>
</div>
)}
@@ -118,7 +118,7 @@ export default function MesGains() {
</div>
<p className="text-gray-500 text-xs mt-1 leading-relaxed">{gain.description}</p>
{gain.value && (
<p className="text-gray-400 text-xs mt-0.5">Valeur : {gain.value} €</p>
<p className="text-gray-500 text-xs mt-0.5">Valeur : {gain.value} €</p>
)}
<div className="flex items-center gap-2 mt-2 flex-wrap">
<p className="text-[#006B6B] text-xs">Gagné le {formatDate(gain.wonAt)}</p>
@@ -128,7 +128,7 @@ export default function MesGains() {
</span>
)}
</div>
<p className="text-gray-400 text-[10px] mt-1">
<p className="text-gray-500 text-[10px] mt-1">
Code utilisé :{' '}
<span className="font-mono tracking-widest text-gray-600">{gain.ticketCode}</span>
</p>

View File

@@ -10,7 +10,7 @@ export default function NotFound() {
Page introuvable
</h1>
<p className="text-gray-400 text-sm sm:text-base max-w-xs mb-8">
<p className="text-gray-500 text-sm sm:text-base max-w-xs mb-8">
La page que vous recherchez n'existe pas ou a été déplacée.
</p>

View File

@@ -9,7 +9,7 @@ export default function Perdu() {
<div className="min-h-screen bg-gray-50 flex flex-col items-center justify-center px-8 max-w-sm mx-auto text-center">
<div className="w-20 h-20 bg-gray-200 rounded-full flex items-center justify-center mb-6">
<svg className="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<svg className="w-10 h-10 text-gray-500" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
@@ -22,7 +22,7 @@ export default function Perdu() {
{message || 'Ce code est invalide ou a déjà été utilisé.'}
</p>
<p className="text-gray-400 text-xs mb-8">
<p className="text-gray-500 text-xs mb-8">
Vérifiez le code présent sur votre ticket et réessayez.
</p>

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import UserNav from '../components/UserNav'
import { changePasswordApi} from '../services/auth'
import { changePasswordApi, meApi, forgotPasswordApi } from '../services/auth'
import { useHandleAuthError } from '../hooks/useHandleAuthError'
export default function Profile() {
@@ -14,6 +14,35 @@ export default function Profile() {
const [pwError, setPwError] = useState('')
const [pwSuccess, setPwSuccess] = useState(false)
// Un compte créé via Google peut ne pas avoir de mot de passe : le
// formulaire de modification n'aurait alors aucun sens. L'information vient
// de l'API, car le profil stocké en localStorage ne la contient pas.
const [hasPassword, setHasPassword] = useState(null)
const [linkSending, setLinkSending] = useState(false)
const [linkSent, setLinkSent] = useState(false)
const [linkError, setLinkError] = useState('')
useEffect(() => {
meApi()
.then((data) => setHasPassword(data.user.hasPassword))
.catch((e) => {
if (!handleAuthError(e)) setHasPassword(true)
})
}, [handleAuthError])
const handleSendSetupLink = async () => {
setLinkError('')
setLinkSending(true)
try {
await forgotPasswordApi(user.email)
setLinkSent(true)
} catch (e) {
if (!handleAuthError(e)) setLinkError(e.message)
} finally {
setLinkSending(false)
}
}
const handlePwChange = (e) => setPwForm({ ...pwForm, [e.target.name]: e.target.value })
const handlePwSubmit = async (e) => {
@@ -65,19 +94,19 @@ export default function Profile() {
{/* Infos du compte (lecture seule) */}
<div className="space-y-4 mb-8">
<div>
<label className="block text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">Adresse email</label>
<div className="w-full border border-gray-100 rounded-lg px-4 py-3 text-sm text-gray-400 bg-gray-50 cursor-not-allowed select-none">
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">Adresse email</label>
<div className="w-full border border-gray-100 rounded-lg px-4 py-3 text-sm text-gray-500 bg-gray-50 cursor-not-allowed select-none">
{user.email || '—'}
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">Nom</label>
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">Nom</label>
<div className="w-full border border-gray-100 rounded-lg px-4 py-3 text-sm text-gray-500 bg-gray-50">
{user.lastname || '—'}
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">Prénom</label>
<label className="block text-xs font-medium text-gray-500 uppercase tracking-wider mb-1">Prénom</label>
<div className="w-full border border-gray-100 rounded-lg px-4 py-3 text-sm text-gray-500 bg-gray-50">
{user.firstname || '—'}
</div>
@@ -89,9 +118,37 @@ export default function Profile() {
{/* Section changement de mot de passe */}
<div>
<h2 className="text-sm font-semibold text-gray-800 mb-4">Changer mon mot de passe</h2>
<h2 className="text-sm font-semibold text-gray-800 mb-4">
{hasPassword === false ? 'Définir un mot de passe' : 'Changer mon mot de passe'}
</h2>
{pwSuccess && (
{hasPassword === false && (
<div className="px-4 py-4 rounded-lg bg-gray-50 border border-gray-200">
<p className="text-sm text-gray-600">
Votre compte a été créé avec Google et n&apos;a pas encore de mot de passe.
Définissez-en un pour pouvoir aussi vous connecter avec votre email.
</p>
{linkSent ? (
<p className="mt-3 text-sm text-green-700">
Lien envoyé à {user.email}. Consultez votre boîte mail.
</p>
) : (
<button
type="button"
onClick={handleSendSetupLink}
disabled={linkSending}
className="mt-3 bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-medium px-4 py-2.5 rounded-lg transition-colors text-sm"
>
{linkSending ? 'Envoi…' : 'Recevoir un lien par email'}
</button>
)}
{linkError && <p className="mt-2 text-sm text-red-600">{linkError}</p>}
</div>
)}
{hasPassword !== false && pwSuccess && (
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm flex items-center gap-2">
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
@@ -100,12 +157,13 @@ export default function Profile() {
</div>
)}
{pwError && (
{hasPassword !== false && pwError && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
{pwError}
</div>
)}
{hasPassword !== false && (
<form onSubmit={handlePwSubmit} className="space-y-4">
{[
{ name: 'currentPassword', label: 'Mot de passe actuel', key: 'current' },
@@ -123,12 +181,12 @@ export default function Profile() {
onChange={handlePwChange}
required
placeholder="••••••••"
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-11 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-11 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
<button
type="button"
onClick={() => setShowPw((s) => ({ ...s, [key]: !s[key] }))}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600"
aria-label="Afficher/masquer"
>
<EyeIcon visible={showPw[key]} />
@@ -145,6 +203,7 @@ export default function Profile() {
{pwLoading ? 'Modification…' : 'Modifier le mot de passe'}
</button>
</form>
)}
</div>
</div>
)

View File

@@ -62,7 +62,7 @@ export default function SaisirCodeUnique() {
placeholder="Votre code"
maxLength={10}
required
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
<button
type="submit"
@@ -144,28 +144,28 @@ export default function SaisirCodeUnique() {
{/* Valeur */}
{gain.value && (
<div className="flex items-center justify-between py-2 border-b border-gray-100">
<span className="text-xs text-gray-400 uppercase tracking-wider">Valeur estimée</span>
<span className="text-xs text-gray-500 uppercase tracking-wider">Valeur estimée</span>
<span className="text-sm font-semibold text-[#006B6B]">{gain.value} €</span>
</div>
)}
{/* Code utilisé */}
<div className="flex items-center justify-between py-2 border-b border-gray-100">
<span className="text-xs text-gray-400 uppercase tracking-wider">Code utilisé</span>
<span className="text-xs text-gray-500 uppercase tracking-wider">Code utilisé</span>
<span className="font-mono text-sm tracking-widest text-gray-700">{code}</span>
</div>
{/* Date & heure */}
{wonAt && (
<div className="flex items-center justify-between py-2 border-b border-gray-100">
<span className="text-xs text-gray-400 uppercase tracking-wider">Obtenu le</span>
<span className="text-xs text-gray-500 uppercase tracking-wider">Obtenu le</span>
<span className="text-sm text-gray-700">{formatDateTime(wonAt)}</span>
</div>
)}
{/* Statut */}
<div className="flex items-center justify-between py-2">
<span className="text-xs text-gray-400 uppercase tracking-wider">Statut</span>
<span className="text-xs text-gray-500 uppercase tracking-wider">Statut</span>
<span className="text-xs font-semibold px-2.5 py-1 rounded-full bg-yellow-100 text-yellow-700">
En attente de remise
</span>

View File

@@ -88,7 +88,7 @@ export default function Sitemap() {
<p className="text-sm font-medium text-gray-800 group-hover:text-[#006B6B] transition-colors">
{link.label}
</p>
<p className="text-xs text-gray-400 mt-0.5">{link.desc}</p>
<p className="text-xs text-gray-500 mt-0.5">{link.desc}</p>
</div>
</Link>
</li>

View File

@@ -49,3 +49,13 @@ export async function getWinnerApi() {
if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status)
return data
}
export async function setGameSettingsApi(startDate) {
const res = await fetch(`${BASE}/game-settings`, {
method: 'PUT',
headers: authHeaders(),
body: JSON.stringify({ startDate }),
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur configuration du jeu.', res.status)
return data
}

View File

@@ -0,0 +1,33 @@
const GA_ID = import.meta.env.VITE_GA_ID
export function initAnalytics() {
if (!GA_ID || window.gtag) return
const script = document.createElement('script')
script.async = true
script.src = `https://www.googletagmanager.com/gtag/js?id=${GA_ID}`
document.head.appendChild(script)
window.dataLayer = window.dataLayer || []
window.gtag = function gtag() {
window.dataLayer.push(arguments)
}
window.gtag('js', new Date())
// Les page_view sont envoyées explicitement lors des changements de route React.
window.gtag('config', GA_ID, {
send_page_view: false,
})
}
export function trackPageView(path) {
if (!GA_ID || !window.gtag) return
window.gtag('event', 'page_view', {
page_path: path,
page_location: window.location.href,
page_title: document.title,
})
}

View File

@@ -18,11 +18,29 @@ export async function loginApi({ email, password }) {
return data
}
export async function registerApi({ email, firstname, lastname, password }) {
export async function registerApi({
email,
firstname,
lastname,
password,
birthdate,
gender,
marketingOptIn,
}) {
const res = await fetch(`${BASE}/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, firstname, lastname, password }),
// birthdate et gender sont facultatifs : on omet la clé plutôt que
// d'envoyer une chaîne vide, que l'API stockerait telle quelle.
body: JSON.stringify({
email,
firstname,
lastname,
password,
birthdate: birthdate || undefined,
gender: gender || undefined,
marketingOptIn: marketingOptIn ?? undefined,
}),
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || "Erreur lors de l'inscription.", res.status)
@@ -40,6 +58,38 @@ export async function verifyCodeApi({ email, code }) {
return data
}
export async function forgotPasswordApi(email) {
const res = await fetch(`${BASE}/forgot-password`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status)
return data
}
export async function resetPasswordApi({ token, password, confirmPassword }) {
const res = await fetch(`${BASE}/reset-password/${token}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password, confirmPassword }),
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur lors de la définition du mot de passe.', res.status)
return data
}
export async function meApi() {
const token = localStorage.getItem('token')
const res = await fetch(`${BASE}/me`, {
headers: { Authorization: `Bearer ${token}` },
})
const data = await res.json()
if (!res.ok) throw apiError(data.message || 'Erreur lors de la récupération du profil.', res.status)
return data
}
export async function deleteAccountApi() {
const token = localStorage.getItem('token')
const res = await fetch(`${BASE}/account`, {

View File

@@ -8,7 +8,7 @@ describe('CookieBanner', () => {
it("s'affiche quand aucun choix n'a été fait", () => {
renderWithProviders(<CookieBanner />)
expect(screen.getByText(/ce site utilise des cookies/i)).toBeInTheDocument()
expect(screen.getByText(/gestion des cookies/i)).toBeInTheDocument()
})
it('enregistre le consentement et disparaît quand on accepte', async () => {

View File

@@ -16,7 +16,7 @@ async function remplirEtSoumettre() {
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean')
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
await userEvent.click(screen.getByRole('checkbox'))
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
}
@@ -49,7 +49,7 @@ describe('Inscription', () => {
const bouton = screen.getByRole('button', { name: /s'inscrire/i })
expect(bouton).toBeDisabled()
await userEvent.click(screen.getByRole('checkbox'))
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
expect(bouton).toBeEnabled()
})
@@ -64,11 +64,64 @@ describe('Inscription', () => {
firstname: 'Jean',
lastname: 'Dupont',
password: 'MotDePasse123!',
birthdate: '',
gender: '',
marketingOptIn: false,
})
expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument()
expect(screen.getByText('jean@example.com')).toBeInTheDocument()
})
it("transmet la date de naissance et le genre saisis", async () => {
registerApi.mockResolvedValue({ message: 'ok' })
renderWithProviders(<Inscription />, { route: '/inscription' })
await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com')
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean')
await userEvent.type(screen.getByLabelText(/date de naissance/i), '1990-05-14')
await userEvent.selectOptions(screen.getByLabelText(/genre/i), 'Femme')
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
expect(registerApi).toHaveBeenCalledWith(
expect.objectContaining({ birthdate: '1990-05-14', gender: 'Femme' })
)
})
it("transmet le consentement marketing quand il est coché", async () => {
registerApi.mockResolvedValue({ message: 'ok' })
renderWithProviders(<Inscription />, { route: '/inscription' })
const marketing = screen.getByRole('checkbox', { name: /offres et actualités/i })
expect(marketing).not.toBeChecked()
await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com')
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean')
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
await userEvent.click(marketing)
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i }))
expect(registerApi).toHaveBeenCalledWith(
expect.objectContaining({ marketingOptIn: true })
)
})
it("n'exige pas le consentement marketing pour s'inscrire", async () => {
renderWithProviders(<Inscription />, { route: '/inscription' })
const bouton = screen.getByRole('button', { name: /s'inscrire/i })
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
expect(screen.getByRole('checkbox', { name: /offres et actualités/i })).not.toBeChecked()
expect(bouton).toBeEnabled()
})
it("affiche l'erreur de l'API si l'inscription échoue", async () => {
registerApi.mockRejectedValue(new Error('Cet email est déjà utilisé.'))