Compare commits
127 Commits
release-1.
...
7da99efc85
| Author | SHA1 | Date | |
|---|---|---|---|
| 7da99efc85 | |||
| f4786e970a | |||
| d207b0221a | |||
| 361cf46905 | |||
| 9492cea1a8 | |||
| d56b91479f | |||
| 7c1e4cf3e2 | |||
| 3a5eed6b96 | |||
| e8080be405 | |||
| 16bcbd731c | |||
| 246303139d | |||
| 82d3daf20f | |||
| 13584d2b0f | |||
| 1e09420705 | |||
| 9bf038278a | |||
| 10cffd77cb | |||
| 25fcd9974a | |||
| 536faaefd5 | |||
| 9edb3255e8 | |||
| b96c0b50ab | |||
| 8dce8b238c | |||
| abd14b713e | |||
| a7cafd1400 | |||
| 6e65c0392c | |||
| e257da41b0 | |||
| 89e49bc56d | |||
| a0869f6f65 | |||
| 4c2849611f | |||
| 279a071a15 | |||
| 1450c0b4c9 | |||
| 95a264dbae | |||
|
|
2201446ee6 | ||
|
|
8a6355cde1 | ||
|
|
28d743deb5 | ||
|
|
dfa5fb81a3 | ||
|
|
a1ab2df975 | ||
| cc495f3b87 | |||
| 4c3e6cd384 | |||
| 55fb17634d | |||
| 570f5d44f5 | |||
|
|
fc9bcbf8da | ||
|
|
d29d2ea052 | ||
|
|
4f0b6d22f2 | ||
|
|
b1272bd008 | ||
|
|
9609a7fcf7 | ||
| 9cf88d9f5a | |||
| 4d7ca7945b | |||
| c22c72e99c | |||
| efe5073aaa | |||
| 39b7b7f8a4 | |||
| f8c02b3cfc | |||
| 35bffc8a0b | |||
| 2d54a7384f | |||
| ab8ea038c0 | |||
| 87ad8988fa | |||
| 23c548992d | |||
| 7513bf45d6 | |||
| bdcdf1a4da | |||
| 79e10946b8 | |||
| 79f463d280 | |||
| 6096da5bb8 | |||
| ad147eb930 | |||
| a09e9f17b5 | |||
| a40a62d600 | |||
| 12685c1b22 | |||
| 3a0dd25ed2 | |||
| a572fa1a15 | |||
| f9d72d45e2 | |||
|
|
2cf399d6d0 | ||
|
|
e895325254 | ||
|
|
fa21d9af2a | ||
|
|
61dcbda581 | ||
|
|
21648ab171 | ||
| 6ee3dfdb87 | |||
| ecda263518 | |||
| 1f11a4e3dd | |||
| 117ebac770 | |||
| d9d2eaba3d | |||
| 047d34f59b | |||
| 0f1ad0b2ef | |||
| b57186ad6d | |||
| a9afa48ec2 | |||
| a1da7b9e17 | |||
| 3e12fa2d01 | |||
| 09e6c86ca0 | |||
| 87577cd14c | |||
|
|
8adf780da2 | ||
|
|
bd46e0982e | ||
|
|
464d666343 | ||
|
|
20c865a071 | ||
| cdbacfebb5 | |||
| 68dbf02c51 | |||
| 02cf98f6ca | |||
| 825fedf118 | |||
| 61296dda0c | |||
| 2bbd2d16e4 | |||
| deb847f26f | |||
| 9bb9ab8e51 | |||
| b15d8458c8 | |||
|
|
2790fc89ff | ||
| 100b75a05a | |||
| d57b26c8c8 | |||
| d071f737bb | |||
|
|
4d31b03116 | ||
|
|
a2543cf4ce | ||
|
|
3258f06ecb | ||
|
|
6d96ea958a | ||
|
|
894ebdc000 | ||
|
|
1c68b2bc0d | ||
|
|
2f7b2d2b64 | ||
|
|
902e304a5e | ||
|
|
7f816c165b | ||
|
|
4f0593bc38 | ||
|
|
b6506435d5 | ||
|
|
65d6b23233 | ||
|
|
68a9650223 | ||
|
|
efd10d6754 | ||
|
|
fba0a7133c | ||
|
|
2d25c91c55 | ||
|
|
1cf2a24ede | ||
|
|
4ea733440f | ||
|
|
4727a3a268 | ||
|
|
428bf2597a | ||
|
|
e2d045c0ab | ||
|
|
25be38027d | ||
|
|
72e7771d95 | ||
|
|
c1ca68da9a |
172
Jenkinsfile
vendored
@@ -8,7 +8,7 @@ pipeline {
|
||||
}
|
||||
|
||||
environment {
|
||||
REGISTRY = "localhost:5000"
|
||||
REGISTRY = "registry.wk-archi-o24a-4-5-g4.fr"
|
||||
|
||||
BACKEND_IMAGE = "${REGISTRY}/the-tip-top/backend"
|
||||
FRONTEND_IMAGE = "${REGISTRY}/the-tip-top/frontend"
|
||||
@@ -48,7 +48,8 @@ pipeline {
|
||||
env.COMPOSE_PROJECT = 'tiptop-dev'
|
||||
env.DEPLOY_NETWORK = 'tiptop-dev'
|
||||
|
||||
env.FRONTEND_API_URL = 'http://192.168.1.23:5001'
|
||||
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'
|
||||
@@ -58,7 +59,8 @@ pipeline {
|
||||
env.COMPOSE_PROJECT = 'tiptop-preprod'
|
||||
env.DEPLOY_NETWORK = 'tiptop-preprod'
|
||||
|
||||
env.FRONTEND_API_URL = 'http://192.168.1.23:5002'
|
||||
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'
|
||||
@@ -68,7 +70,8 @@ pipeline {
|
||||
env.COMPOSE_PROJECT = 'tiptop-prod'
|
||||
env.DEPLOY_NETWORK = 'tiptop-prod'
|
||||
|
||||
env.FRONTEND_API_URL = 'http://192.168.1.23:5003'
|
||||
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,12 +81,19 @@ pipeline {
|
||||
env.COMPOSE_PROJECT = ''
|
||||
env.DEPLOY_NETWORK = ''
|
||||
env.FRONTEND_API_URL = ''
|
||||
env.FRONTEND_GA_ID = ""
|
||||
}
|
||||
env.TURNSTILE_SITE_KEY = sh(
|
||||
script: "grep '^TURNSTILE_SITE_KEY=' /opt/furious-ducks/secrets/tiptop-mail.env | cut -d= -f2-",
|
||||
returnStdout: true
|
||||
).trim()
|
||||
|
||||
echo "Branche : ${env.BRANCH_NAME}"
|
||||
echo "Tag Docker : ${env.IMAGE_TAG}"
|
||||
echo "Environnement : ${env.DEPLOY_ENV}"
|
||||
echo "Déploiement activé : ${env.DEPLOY_ENABLED}"
|
||||
echo "API frontend : ${env.FRONTEND_API_URL}"
|
||||
echo "Turnstile : ${env.TURNSTILE_SITE_KEY ? '[SET]' : '[EMPTY]'}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,6 +146,10 @@ pipeline {
|
||||
|
||||
sleep 2
|
||||
done
|
||||
docker run --rm \
|
||||
-v tiptop-npm-cache-backend:/npm-cache \
|
||||
alpine \
|
||||
sh -c 'chown -R 1000:1000 /npm-cache'
|
||||
|
||||
docker run --rm \
|
||||
--network "${CI_NETWORK}" \
|
||||
@@ -158,20 +172,60 @@ pipeline {
|
||||
npm ci --prefer-offline --no-audit --no-fund &&
|
||||
npx prisma generate &&
|
||||
npx prisma migrate deploy &&
|
||||
npx prisma db seed &&
|
||||
npm test
|
||||
"
|
||||
'''
|
||||
}
|
||||
|
||||
post {
|
||||
always {
|
||||
junit(
|
||||
testResults: 'backend/test-results/*.xml',
|
||||
allowEmptyResults: true
|
||||
)
|
||||
|
||||
recordCoverage(
|
||||
tools: [[
|
||||
parser: 'LCOV',
|
||||
pattern: 'backend/coverage/lcov.info'
|
||||
]],
|
||||
sourceCodeRetention: 'EVERY_BUILD',
|
||||
id: 'backend-coverage',
|
||||
name: 'Backend Coverage'
|
||||
)
|
||||
|
||||
publishHTML([
|
||||
allowMissing: true,
|
||||
alwaysLinkToLastBuild: true,
|
||||
keepAll: true,
|
||||
reportDir: 'backend/coverage',
|
||||
reportFiles: 'index.html',
|
||||
reportName: 'Backend Coverage HTML'
|
||||
])
|
||||
|
||||
archiveArtifacts(
|
||||
artifacts: 'backend/coverage/**',
|
||||
fingerprint: true,
|
||||
allowEmptyArchive: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('Frontend - Lint et Build') {
|
||||
stage('Frontend - Lint, Tests et Build') {
|
||||
steps {
|
||||
sh '''
|
||||
docker run --rm \
|
||||
-v tiptop-npm-cache-frontend:/npm-cache \
|
||||
alpine \
|
||||
sh -c 'chown -R 1000:1000 /npm-cache'
|
||||
|
||||
docker run --rm \
|
||||
--volumes-from furious-jenkins \
|
||||
-v tiptop-npm-cache-frontend:/npm-cache \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-e HOME=/tmp \
|
||||
-e CHROMIUM_PATH=/usr/bin/chromium \
|
||||
-e NODE_OPTIONS=--dns-result-order=ipv4first \
|
||||
-e NPM_CONFIG_CACHE=/npm-cache \
|
||||
-e NPM_CONFIG_FETCH_RETRIES=5 \
|
||||
@@ -181,14 +235,96 @@ pipeline {
|
||||
-w "${WORKSPACE}/frontend" \
|
||||
node:22-alpine \
|
||||
sh -c "
|
||||
apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont &&
|
||||
export CHROMIUM_PATH=/usr/bin/chromium &&
|
||||
npm ci --prefer-offline --no-audit --no-fund &&
|
||||
npm run lint &&
|
||||
npm run test:coverage &&
|
||||
npm run build
|
||||
"
|
||||
'''
|
||||
}
|
||||
post {
|
||||
always {
|
||||
junit(
|
||||
testResults: 'frontend/test-results/*.xml',
|
||||
allowEmptyResults: true
|
||||
)
|
||||
|
||||
recordCoverage(
|
||||
tools: [[
|
||||
parser: 'LCOV',
|
||||
pattern: 'frontend/coverage/lcov.info'
|
||||
]],
|
||||
sourceCodeRetention: 'EVERY_BUILD',
|
||||
id: 'frontend-coverage',
|
||||
name: 'Frontend Coverage'
|
||||
)
|
||||
|
||||
publishHTML([
|
||||
allowMissing: true,
|
||||
alwaysLinkToLastBuild: true,
|
||||
keepAll: true,
|
||||
reportDir: 'frontend/coverage',
|
||||
reportFiles: 'index.html',
|
||||
reportName: 'Frontend Coverage HTML'
|
||||
])
|
||||
|
||||
archiveArtifacts(
|
||||
artifacts: 'frontend/coverage/**',
|
||||
fingerprint: true,
|
||||
allowEmptyArchive: true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('SonarQube Analysis') {
|
||||
when {
|
||||
branch 'develop'
|
||||
}
|
||||
|
||||
tools {
|
||||
nodejs 'node22'
|
||||
}
|
||||
|
||||
steps {
|
||||
script {
|
||||
def scannerHome = tool 'sonar-scanner'
|
||||
|
||||
withEnv(["SCANNER_HOME=${scannerHome}"]) {
|
||||
withSonarQubeEnv('sonarqube') {
|
||||
sh '''
|
||||
echo "Node utilisé par Sonar :"
|
||||
node -v
|
||||
|
||||
"${SCANNER_HOME}/bin/sonar-scanner" \
|
||||
-Dsonar.projectKey=THE-TIP-TOP \
|
||||
-Dsonar.projectName=THE-TIP-TOP \
|
||||
-Dsonar.sources=backend/src,frontend/src \
|
||||
-Dsonar.tests=backend/tests,frontend/src/test \
|
||||
-Dsonar.exclusions=frontend/src/test/**,backend/tests/**,**/node_modules/**,**/coverage/**,**/dist/** \
|
||||
-Dsonar.test.inclusions=backend/tests/**,frontend/src/test/** \
|
||||
-Dsonar.javascript.lcov.reportPaths=backend/coverage/lcov.info,frontend/coverage/lcov.info \
|
||||
-Dsonar.sourceEncoding=UTF-8
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
stage('SonarQube Quality Gate') {
|
||||
when {
|
||||
branch 'develop'
|
||||
}
|
||||
|
||||
steps {
|
||||
timeout(time: 5, unit: 'MINUTES') {
|
||||
waitForQualityGate abortPipeline: true
|
||||
}
|
||||
}
|
||||
}
|
||||
stage('Construction des images Docker') {
|
||||
when {
|
||||
expression {
|
||||
@@ -198,12 +334,21 @@ pipeline {
|
||||
|
||||
steps {
|
||||
sh '''
|
||||
set -a
|
||||
. /opt/furious-ducks/secrets/tiptop-mail.env
|
||||
set +a
|
||||
|
||||
test -n "${TURNSTILE_SITE_KEY}"
|
||||
echo "Turnstile site key chargée (longueur: ${#TURNSTILE_SITE_KEY})"
|
||||
|
||||
docker build \
|
||||
-t "${BACKEND_IMAGE}:${IMAGE_TAG}" \
|
||||
backend
|
||||
|
||||
docker build \
|
||||
--build-arg VITE_API_URL="${FRONTEND_API_URL}" \
|
||||
--build-arg VITE_GA_ID="${FRONTEND_GA_ID}" \
|
||||
--build-arg VITE_TURNSTILE_SITE_KEY="${TURNSTILE_SITE_KEY:-}" \
|
||||
-t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \
|
||||
frontend
|
||||
'''
|
||||
@@ -218,12 +363,26 @@ pipeline {
|
||||
}
|
||||
|
||||
steps {
|
||||
withCredentials([
|
||||
usernamePassword(
|
||||
credentialsId: 'registry-credentials',
|
||||
usernameVariable: 'REGISTRY_USER',
|
||||
passwordVariable: 'REGISTRY_PASSWORD'
|
||||
)
|
||||
]) {
|
||||
sh '''
|
||||
echo "${REGISTRY_PASSWORD}" | \
|
||||
docker login "${REGISTRY}" \
|
||||
-u "${REGISTRY_USER}" \
|
||||
--password-stdin
|
||||
|
||||
docker push "${BACKEND_IMAGE}:${IMAGE_TAG}"
|
||||
docker push "${FRONTEND_IMAGE}:${IMAGE_TAG}"
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
stage('Validation production') {
|
||||
when {
|
||||
@@ -317,6 +476,7 @@ pipeline {
|
||||
post {
|
||||
always {
|
||||
sh '''
|
||||
docker logout "${REGISTRY}" 2>/dev/null || true
|
||||
docker rm -f "${CI_DATABASE}" 2>/dev/null || true
|
||||
docker network rm "${CI_NETWORK}" 2>/dev/null || true
|
||||
'''
|
||||
|
||||
781
README.md
Normal 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.
|
||||
@@ -4,9 +4,23 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db"
|
||||
|
||||
JWT_SECRET="your_secret_key"
|
||||
|
||||
APP_URL=http://localhost:5000
|
||||
MAIL_HOST=smtp.gmail.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USER=yourmail_adress@gmail.com
|
||||
MAIL_PASS=_______________________
|
||||
MAIL_FROM="Thé Tip Top <yourmail_adress@gmail.com>"
|
||||
APP_URL="http://localhost:5000"
|
||||
FRONTEND_URL="http://localhost:5173"
|
||||
MAIL_HOST="smtp.gmail.com"
|
||||
MAIL_PORT="587"
|
||||
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=""
|
||||
|
||||
# Cloudflare Turnstile (anti-robot sur inscription et connexion). Laisser vide
|
||||
# désactive la vérification. La clé publique va dans frontend/.env
|
||||
# (VITE_TURNSTILE_SITE_KEY).
|
||||
TURNSTILE_SECRET_KEY=""
|
||||
|
||||
13
backend/.gitignore
vendored
@@ -3,3 +3,16 @@ node_modules/
|
||||
.prisma
|
||||
coverage/
|
||||
dist/
|
||||
test-results/
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -2,367 +2,284 @@
|
||||
|
||||
## 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.
|
||||
|
||||
## Rôles
|
||||
|
||||
### CLIENT
|
||||
- participer au jeu ;
|
||||
- consulter ses gains ;
|
||||
- gérer son profil.
|
||||
|
||||
### EMPLOYEE
|
||||
- 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.
|
||||
|
||||
## Jeu-concours
|
||||
|
||||
Phases applicatives :
|
||||
|
||||
- `NOT_CONFIGURED`
|
||||
- `SCHEDULED`
|
||||
- `OPEN`
|
||||
- `CLAIM_ONLY`
|
||||
- `CLOSED`
|
||||
|
||||
Principales règles métier :
|
||||
|
||||
- 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.
|
||||
|
||||
## Notifications email
|
||||
|
||||
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.
|
||||
|
||||
## Sécurité
|
||||
|
||||
- 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.
|
||||
|
||||
> Ne jamais versionner les mots de passe, tokens, secrets JWT, secrets OAuth ou identifiants SMTP.
|
||||
|
||||
## Modèle de données principal
|
||||
|
||||
- `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.
|
||||
|
||||
## Installation locale
|
||||
|
||||
Prérequis : Node.js 22, npm et PostgreSQL 16.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Variables d'environnement
|
||||
Exemple de `.env` local :
|
||||
|
||||
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
|
||||
```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=""
|
||||
```
|
||||
|
||||
### Générer Prisma Client
|
||||
Ces valeurs sont uniquement des exemples.
|
||||
|
||||
## Prisma
|
||||
|
||||
```bash
|
||||
npx prisma generate
|
||||
```
|
||||
|
||||
### Exécuter le seed de la base
|
||||
|
||||
```bash
|
||||
npx prisma migrate dev
|
||||
npx prisma migrate deploy
|
||||
npx prisma db seed
|
||||
```
|
||||
|
||||
### Générer les tickets du jeu-concours
|
||||
|
||||
```bash
|
||||
npm run generate:tickets
|
||||
```
|
||||
|
||||
### Ouvrir Prisma Studio
|
||||
|
||||
```bash
|
||||
npx prisma studio
|
||||
```
|
||||
|
||||
---
|
||||
## Lancement
|
||||
|
||||
## 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.
|
||||
Développement :
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
```
|
||||
|
||||
Serveur :
|
||||
Démarrage standard :
|
||||
|
||||
```txt [http://localhost:**5000**](http://localhost:**5000**) ```
|
||||
```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
|
||||
|
||||
La documentation Swagger est accessible à l'adresse :
|
||||
Swagger UI est accessible localement sur :
|
||||
|
||||
```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
|
||||
|
||||
### CLIENT
|
||||
|
||||
Peut :
|
||||
|
||||
- Participer au jeu-concours
|
||||
- Consulter ses gains
|
||||
- Consulter son profil
|
||||
|
||||
### EMPLOYEE
|
||||
|
||||
Peut :
|
||||
|
||||
- Rechercher un gain
|
||||
- Vérifier les informations du gagnant
|
||||
- Valider la remise d'un lot
|
||||
|
||||
### ADMIN
|
||||
|
||||
Peut :
|
||||
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
## Routes API
|
||||
|
||||
### Auth
|
||||
|
||||
| Méthode | Route |
|
||||
| ------- | ------------------ |
|
||||
| POST | /api/auth/register |
|
||||
| POST | /api/auth/login |
|
||||
| GET | /api/auth/me |
|
||||
|
||||
### Game
|
||||
|
||||
| Méthode | Route |
|
||||
| ------- | --------------------- |
|
||||
| POST | /api/game/participate |
|
||||
| GET | /api/game/my-gains |
|
||||
|
||||
### Employee
|
||||
|
||||
| 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
|
||||
|
||||
---
|
||||
|
||||
## Modèle de données principal
|
||||
|
||||
### User
|
||||
|
||||
- **CLIENT**
|
||||
- **EMPLOYEE**
|
||||
- **ADMIN**
|
||||
|
||||
### Ticket
|
||||
|
||||
- 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
|
||||
```text
|
||||
http://localhost:5000/api-docs
|
||||
```
|
||||
|
||||
---
|
||||
## CI/CD
|
||||
|
||||
## Règles métier
|
||||
Le projet utilise Gitea, Jenkins, Docker et un Registry privé.
|
||||
|
||||
- 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.
|
||||
```text
|
||||
feature/* → CI uniquement
|
||||
develop → DEV automatique
|
||||
release-* → PREPROD automatique
|
||||
main → validation manuelle → PROD
|
||||
```
|
||||
|
||||
---
|
||||
Pipeline :
|
||||
|
||||
## Auteur
|
||||
```text
|
||||
Checkout
|
||||
↓
|
||||
Tests backend + PostgreSQL temporaire
|
||||
↓
|
||||
Lint + Build frontend
|
||||
↓
|
||||
Docker Build
|
||||
↓
|
||||
Registry
|
||||
↓
|
||||
Déploiement
|
||||
↓
|
||||
Contrôle des conteneurs
|
||||
```
|
||||
|
||||
Projet réalisé dans le cadre de la certification : **Expert en Stratégie et Transformation Digitale**
|
||||
## 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**.
|
||||
@@ -1,26 +1,35 @@
|
||||
module.exports = {
|
||||
testEnvironment: "node",
|
||||
|
||||
// Dossier des tests
|
||||
testMatch: ["**/tests/**/*.test.js"],
|
||||
|
||||
// Nettoyage des mocks entre les tests
|
||||
setupFiles: ["<rootDir>/tests/setupEnv.js"],
|
||||
|
||||
clearMocks: true,
|
||||
|
||||
// Timeout (utile pour Prisma / DB)
|
||||
testTimeout: 10000,
|
||||
|
||||
// Affichage des logs de test
|
||||
verbose: true,
|
||||
|
||||
// Couverture de code (optionnel mais bon pour soutenance)
|
||||
collectCoverage: true,
|
||||
coverageDirectory: "coverage",
|
||||
|
||||
coverageReporters: [
|
||||
"text",
|
||||
"lcov",
|
||||
"html"
|
||||
],
|
||||
|
||||
// Fichiers à ignorer
|
||||
testPathIgnorePatterns: ["/node_modules/"],
|
||||
reporters: [
|
||||
"default",
|
||||
[
|
||||
"jest-junit",
|
||||
{
|
||||
outputDirectory: "./test-results",
|
||||
outputName: "junit.xml"
|
||||
}
|
||||
]
|
||||
],
|
||||
|
||||
// Configuration pour Jest avec Supertest
|
||||
//setupFilesAfterEnv: ["<rootDir>/tests/setup.js"]
|
||||
testPathIgnorePatterns: ["/node_modules/"]
|
||||
};
|
||||
228
backend/package-lock.json
generated
@@ -13,8 +13,13 @@
|
||||
"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.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"passport": "^0.7.0",
|
||||
"passport-facebook": "^3.0.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"prisma": "^6.19.3",
|
||||
"swagger-jsdoc": "^6.3.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
@@ -25,6 +30,7 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"jest": "^30.4.2",
|
||||
"jest-junit": "^17.0.0",
|
||||
"nodemon": "^3.1.14",
|
||||
"supertest": "^7.2.2"
|
||||
}
|
||||
@@ -604,22 +610,22 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
|
||||
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"@emnapi/wasi-threads": "1.2.3",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
|
||||
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -629,9 +635,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -2196,6 +2202,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",
|
||||
@@ -3209,6 +3224,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",
|
||||
@@ -3657,6 +3691,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",
|
||||
@@ -3765,6 +3811,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",
|
||||
@@ -4457,6 +4512,22 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-junit": {
|
||||
"version": "17.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jest-junit/-/jest-junit-17.0.0.tgz",
|
||||
"integrity": "sha512-RYWCkq4j59gUXj5DsgbIE7xFBZzu1gtibPhyjSjMmGaOTLnqlXhg7x9zuGCwgbCuMAyoyvk0Mi8wSrRR5uOeLA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"mkdirp": "^1.0.4",
|
||||
"strip-ansi": "^6.0.1",
|
||||
"uuid": "^14.0.0",
|
||||
"xml": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jest-leak-detector": {
|
||||
"version": "30.4.1",
|
||||
"resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz",
|
||||
@@ -5375,6 +5446,19 @@
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mkdirp": "bin/cmd.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -5457,9 +5541,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
||||
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
|
||||
"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"
|
||||
@@ -5578,6 +5662,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",
|
||||
@@ -5738,6 +5828,75 @@
|
||||
"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-facebook": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-facebook/-/passport-facebook-3.0.0.tgz",
|
||||
"integrity": "sha512-K/qNzuFsFISYAyC1Nma4qgY/12V3RSLFdFVsPKXiKZt434wOvthFW1p7zKa1iQihQMRhaWorVE1o3Vi1o+ZgeQ==",
|
||||
"dependencies": {
|
||||
"passport-oauth2": "1.x.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/passport-google-oauth20": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
|
||||
"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",
|
||||
@@ -5799,6 +5958,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",
|
||||
@@ -6744,6 +6908,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",
|
||||
@@ -6836,6 +7006,29 @@
|
||||
"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",
|
||||
"integrity": "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-to-istanbul": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz",
|
||||
@@ -6942,6 +7135,13 @@
|
||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
|
||||
@@ -20,8 +20,13 @@
|
||||
"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.1",
|
||||
"nodemailer": "^9.0.3",
|
||||
"passport": "^0.7.0",
|
||||
"passport-facebook": "^3.0.0",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"prisma": "^6.19.3",
|
||||
"swagger-jsdoc": "^6.3.0",
|
||||
"swagger-ui-express": "^5.0.1",
|
||||
@@ -32,6 +37,7 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"jest": "^30.4.2",
|
||||
"jest-junit": "^17.0.0",
|
||||
"nodemon": "^3.1.14",
|
||||
"supertest": "^7.2.2"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "GameSettings" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"startDate" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "GameSettings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -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");
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "mustChangePassword" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Les emails sont désormais stockés et recherchés en minuscules, sans espaces
|
||||
-- autour (voir src/validations/email.validation.js). On aligne les comptes
|
||||
-- existants, sauf ceux dont la version normalisée appartient déjà à un autre
|
||||
-- compte : les modifier violerait la contrainte d'unicité et ferait échouer le
|
||||
-- déploiement. Ces doublons éventuels sont à traiter à la main.
|
||||
UPDATE "User" AS u
|
||||
SET "email" = lower(trim(u."email"))
|
||||
WHERE u."email" <> lower(trim(u."email"))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM "User" AS other
|
||||
WHERE other."id" <> u."id"
|
||||
AND lower(trim(other."email")) = lower(trim(u."email"))
|
||||
);
|
||||
@@ -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,21 @@ model User {
|
||||
resetPasswordToken String?
|
||||
resetPasswordExpires DateTime?
|
||||
|
||||
// Compte employé créé par un admin avec un mot de passe provisoire envoyé
|
||||
// par email : tant qu'il vaut true, seul le choix d'un nouveau mot de passe
|
||||
// est autorisé (voir auth.middleware.js).
|
||||
mustChangePassword Boolean @default(false)
|
||||
|
||||
provider AuthProvider @default(LOCAL)
|
||||
providerId String?
|
||||
avatarUrl String?
|
||||
|
||||
tickets Ticket[]
|
||||
participations Participation[]
|
||||
grandPrizeWinner GrandPrizeWinner?
|
||||
claimsValidated Claim[] @relation("EmployeeClaims")
|
||||
|
||||
@@unique([provider, providerId])
|
||||
}
|
||||
|
||||
model Lot {
|
||||
@@ -116,3 +135,12 @@ model GrandPrizeWinner {
|
||||
prize String @default("Un an de thé d'une valeur de 360€")
|
||||
drawnAt DateTime @default(now())
|
||||
}
|
||||
|
||||
// Ligne unique de configuration : date de début du jeu-concours.
|
||||
// Participation et réclamation des lots sont autorisées pendant 60 jours
|
||||
// (30 jours de jeu + 30 jours supplémentaires) à compter de startDate.
|
||||
model GameSettings {
|
||||
id Int @id @default(autoincrement())
|
||||
startDate DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const prisma = require("../src/config/prisma");
|
||||
const { DEFAULT_START_DATE } = require("../src/utils/gameWindow");
|
||||
|
||||
async function main() {
|
||||
await prisma.lot.deleteMany();
|
||||
@@ -39,6 +40,14 @@ async function main() {
|
||||
});
|
||||
|
||||
console.log("Lots créés avec succès");
|
||||
|
||||
const existingSettings = await prisma.gameSettings.findFirst();
|
||||
if (!existingSettings) {
|
||||
await prisma.gameSettings.create({ data: { startDate: DEFAULT_START_DATE } });
|
||||
console.log("Date de début du jeu-concours initialisée au 2 octobre 2026.");
|
||||
} else {
|
||||
console.log("Date de début du jeu-concours déjà définie, inchangée.");
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
171
backend/src/config/passport.js
Normal file
@@ -0,0 +1,171 @@
|
||||
const passport = require("passport");
|
||||
const GoogleStrategy = require("passport-google-oauth20").Strategy;
|
||||
const FacebookStrategy = require("passport-facebook").Strategy;
|
||||
const crypto = require("crypto");
|
||||
const prisma = require("./prisma");
|
||||
const transporter = require("./mailer");
|
||||
const getFrontendUrl = require("../utils/frontendUrl");
|
||||
const { normalizeEmail } = require("../validations/email.validation");
|
||||
|
||||
const PROVIDER_LABELS = { GOOGLE: "Google", FACEBOOK: "Facebook" };
|
||||
|
||||
// Un compte créé via un provider externe n'a pas de mot de passe. On lui en
|
||||
// propose un dès l'inscription, via le même mécanisme de jeton que la
|
||||
// réinitialisation : sans mot de passe, l'utilisateur dépend entièrement du
|
||||
// provider pour se connecter, et le formulaire de changement de son profil lui
|
||||
// reste inaccessible.
|
||||
const PASSWORD_SETUP_EXPIRY_MS = 1000 * 60 * 60 * 24; // 24 h
|
||||
|
||||
async function sendPasswordSetupEmail(user, providerLabel) {
|
||||
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 setupUrl = `${getFrontendUrl()}/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 ${providerLabel}. Vous pouvez continuer à vous
|
||||
connecter de cette façon, ou définir un mot de passe pour accéder
|
||||
également à votre compte sans passer par ${providerLabel}.</p>
|
||||
<p style="text-align:center;padding:20px 0">
|
||||
<a href="${setupUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
|
||||
Définir mon mot de passe
|
||||
</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 ${providerLabel} continuera de fonctionner.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
// Callback de vérification commun à tous les providers : passport normalise
|
||||
// le profil (id, name, emails, photos), seule la valeur de `provider` change.
|
||||
function verifyOAuthProfile(provider) {
|
||||
const providerLabel = PROVIDER_LABELS[provider];
|
||||
|
||||
return async (accessToken, refreshToken, profile, done) => {
|
||||
try {
|
||||
const email = normalizeEmail(profile.emails?.[0]?.value);
|
||||
|
||||
// Cas fréquent chez Facebook : compte ouvert avec un numéro de téléphone.
|
||||
if (!email) {
|
||||
return done(null, false, {
|
||||
message: `Aucune adresse email n'est associée à ce compte ${providerLabel}.`,
|
||||
});
|
||||
}
|
||||
|
||||
// 1. Compte déjà lié à ce compte externe : connexion directe.
|
||||
const byProvider = await prisma.user.findUnique({
|
||||
where: {
|
||||
provider_providerId: { provider, providerId: profile.id },
|
||||
},
|
||||
});
|
||||
|
||||
if (byProvider) return done(null, byProvider);
|
||||
|
||||
// 2. Un compte existe avec le même email : on le rattache au provider.
|
||||
// L'email étant déjà validé par le provider, on en profite pour lever
|
||||
// la vérification si elle était en attente.
|
||||
const byEmail = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
if (byEmail) {
|
||||
const linked = await prisma.user.update({
|
||||
where: { id: byEmail.id },
|
||||
data: {
|
||||
provider,
|
||||
providerId: profile.id,
|
||||
isEmailVerified: true,
|
||||
emailVerifyToken: null,
|
||||
emailVerifyExpires: null,
|
||||
avatarUrl: byEmail.avatarUrl || profile.photos?.[0]?.value || null,
|
||||
},
|
||||
});
|
||||
return done(null, linked);
|
||||
}
|
||||
|
||||
// 3. Première connexion : création du compte.
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
firstname: profile.name?.givenName || profile.displayName || "Utilisateur",
|
||||
lastname: profile.name?.familyName || "",
|
||||
email,
|
||||
password: null,
|
||||
provider,
|
||||
providerId: profile.id,
|
||||
avatarUrl: profile.photos?.[0]?.value || null,
|
||||
isEmailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Un envoi qui échoue ne doit pas bloquer la connexion : le compte
|
||||
// est valide, et « mot de passe oublié » permet de rattraper.
|
||||
try {
|
||||
await sendPasswordSetupEmail(created, providerLabel);
|
||||
} catch (mailError) {
|
||||
console.error("Erreur envoi email de definition du mot de passe:", mailError);
|
||||
}
|
||||
|
||||
return done(null, created);
|
||||
} catch (error) {
|
||||
return done(error);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Authentification stateless : le callback OAuth émet directement notre JWT,
|
||||
// donc pas de session Express ni de serialize/deserialize à brancher.
|
||||
|
||||
const apiUrl = process.env.APP_URL || "http://localhost:5000";
|
||||
|
||||
const googleEnabled =
|
||||
Boolean(process.env.GOOGLE_CLIENT_ID) && Boolean(process.env.GOOGLE_CLIENT_SECRET);
|
||||
|
||||
const facebookEnabled =
|
||||
Boolean(process.env.FACEBOOK_APP_ID) && Boolean(process.env.FACEBOOK_APP_SECRET);
|
||||
|
||||
if (googleEnabled) {
|
||||
passport.use(
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
||||
callbackURL: `${apiUrl}/api/auth/google/callback`,
|
||||
scope: ["profile", "email"],
|
||||
},
|
||||
verifyOAuthProfile("GOOGLE")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (facebookEnabled) {
|
||||
passport.use(
|
||||
new FacebookStrategy(
|
||||
{
|
||||
clientID: process.env.FACEBOOK_APP_ID,
|
||||
clientSecret: process.env.FACEBOOK_APP_SECRET,
|
||||
callbackURL: `${apiUrl}/api/auth/facebook/callback`,
|
||||
// Sans profileFields, la Graph API ne renvoie que l'id et le nom.
|
||||
profileFields: ["id", "displayName", "name", "emails", "photos"],
|
||||
// appsecret_proof : empêche l'usage d'un access token volé ailleurs.
|
||||
enableProof: true,
|
||||
},
|
||||
verifyOAuthProfile("FACEBOOK")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = { passport, googleEnabled, facebookEnabled, verifyOAuthProfile };
|
||||
@@ -1,8 +1,43 @@
|
||||
const bcrypt = require("bcrypt");
|
||||
const crypto = require("crypto");
|
||||
const prisma = require("../config/prisma");
|
||||
const transporter = require("../config/mailer");
|
||||
const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow");
|
||||
const getFrontendUrl = require("../utils/frontendUrl");
|
||||
|
||||
const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"];
|
||||
|
||||
function getAgeBracket(birthdate) {
|
||||
if (!birthdate) return "unknown";
|
||||
|
||||
const now = new Date();
|
||||
let age = now.getFullYear() - birthdate.getFullYear();
|
||||
const hasHadBirthdayThisYear =
|
||||
now.getMonth() > birthdate.getMonth() ||
|
||||
(now.getMonth() === birthdate.getMonth() && now.getDate() >= birthdate.getDate());
|
||||
if (!hasHadBirthdayThisYear) age -= 1;
|
||||
|
||||
if (age < 18) return "-18";
|
||||
if (age <= 24) return "18-24";
|
||||
if (age <= 34) return "25-34";
|
||||
if (age <= 44) return "35-44";
|
||||
if (age <= 54) return "45-54";
|
||||
if (age <= 64) return "55-64";
|
||||
return "65+";
|
||||
}
|
||||
|
||||
function computeAgeStats(users) {
|
||||
const counts = Object.fromEntries([...AGE_BRACKETS, "unknown"].map((b) => [b, 0]));
|
||||
for (const user of users) {
|
||||
counts[getAgeBracket(user.birthdate)] += 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
const getStats = async (req, res) => {
|
||||
try {
|
||||
const gameWindow = await getGameWindow();
|
||||
|
||||
const [
|
||||
ticketsTotal,
|
||||
ticketsUsed,
|
||||
@@ -13,6 +48,7 @@ const getStats = async (req, res) => {
|
||||
users,
|
||||
men,
|
||||
women,
|
||||
clientsWithBirthdate,
|
||||
] = await Promise.all([
|
||||
prisma.ticket.count(),
|
||||
prisma.ticket.count({ where: { isUsed: true } }),
|
||||
@@ -23,6 +59,7 @@ const getStats = async (req, res) => {
|
||||
prisma.user.count({ where: { role: "CLIENT" } }),
|
||||
prisma.user.count({ where: { role: "CLIENT", gender: "Homme" } }),
|
||||
prisma.user.count({ where: { role: "CLIENT", gender: "Femme" } }),
|
||||
prisma.user.findMany({ where: { role: "CLIENT" }, select: { birthdate: true } }),
|
||||
]);
|
||||
|
||||
const lotsStats = await prisma.lot.findMany({
|
||||
@@ -44,10 +81,14 @@ 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),
|
||||
genderStats: {
|
||||
men,
|
||||
women,
|
||||
@@ -126,9 +167,30 @@ const getEmailingContacts = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Sans caractères ambigus (0/O, 1/l/I) : l'employé peut devoir le recopier.
|
||||
const TEMP_PASSWORD_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||
// Aucun caractère à échapper dans l'email HTML (&, <, >, guillemets).
|
||||
const TEMP_PASSWORD_SPECIALS = "!@#$%*?";
|
||||
const TEMP_PASSWORD_LENGTH = 12;
|
||||
|
||||
// Respecte la règle commune des mots de passe (au moins un caractère spécial),
|
||||
// placé à une position aléatoire.
|
||||
function generateTempPassword() {
|
||||
const chars = [];
|
||||
for (let i = 0; i < TEMP_PASSWORD_LENGTH - 1; i++) {
|
||||
chars.push(TEMP_PASSWORD_ALPHABET[crypto.randomInt(TEMP_PASSWORD_ALPHABET.length)]);
|
||||
}
|
||||
const special = TEMP_PASSWORD_SPECIALS[crypto.randomInt(TEMP_PASSWORD_SPECIALS.length)];
|
||||
chars.splice(crypto.randomInt(chars.length + 1), 0, special);
|
||||
return chars.join("");
|
||||
}
|
||||
|
||||
// L'admin ne choisit pas le mot de passe de l'employé : on en génère un
|
||||
// provisoire, envoyé uniquement par email, que l'employé devra remplacer à
|
||||
// sa première connexion (mustChangePassword).
|
||||
const createEmployee = async (req, res) => {
|
||||
try {
|
||||
const { firstname, lastname, email, password, birthdate, gender } = req.body;
|
||||
const { firstname, lastname, email, birthdate, gender } = req.body;
|
||||
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
@@ -140,7 +202,8 @@ const createEmployee = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const tempPassword = generateTempPassword();
|
||||
const hashedPassword = await bcrypt.hash(tempPassword, 10);
|
||||
|
||||
const employee = await prisma.user.create({
|
||||
data: {
|
||||
@@ -152,6 +215,8 @@ const createEmployee = async (req, res) => {
|
||||
gender,
|
||||
role: "EMPLOYEE",
|
||||
marketingOptIn: false,
|
||||
isEmailVerified: true,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -163,8 +228,45 @@ const createEmployee = async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
const loginUrl = `${getFrontendUrl()}/connexion`;
|
||||
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: process.env.MAIL_FROM,
|
||||
to: email,
|
||||
subject: "Votre compte employé Thé Tip Top",
|
||||
html: `
|
||||
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||
<h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
|
||||
<p>Un compte employé Thé Tip Top vient d'être créé pour vous.</p>
|
||||
<p>Voici vos identifiants de première connexion :</p>
|
||||
<p style="background:#f3f4f6;padding:12px 16px;border-radius:8px">
|
||||
Email : <strong>${email}</strong><br />
|
||||
Mot de passe provisoire : <strong style="font-family:monospace;font-size:16px">${tempPassword}</strong>
|
||||
</p>
|
||||
<p style="text-align:center;padding:20px 0">
|
||||
<a href="${loginUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
|
||||
Me connecter
|
||||
</a>
|
||||
</p>
|
||||
<p style="color:#888;font-size:13px">Dès votre première connexion, vous devrez
|
||||
choisir un nouveau mot de passe. Le mot de passe provisoire ne fonctionnera
|
||||
plus ensuite.</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
} catch (mailError) {
|
||||
// Sans l'email, l'employé n'a aucun moyen de connaître son mot de passe :
|
||||
// on annule la création pour que l'admin puisse simplement réessayer.
|
||||
console.error("Erreur envoi email employé:", mailError);
|
||||
await prisma.user.delete({ where: { id: employee.id } });
|
||||
return res.status(502).json({
|
||||
message: "L'email d'invitation n'a pas pu être envoyé : le compte n'a pas été créé. Réessayez.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
message: "Employé créé avec succès.",
|
||||
message: "Employé créé. Un email avec un mot de passe provisoire lui a été envoyé.",
|
||||
employee,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -177,6 +279,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: {
|
||||
@@ -197,6 +324,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",
|
||||
@@ -218,7 +349,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({
|
||||
@@ -237,9 +372,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) {
|
||||
@@ -250,6 +417,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({
|
||||
@@ -265,14 +478,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);
|
||||
@@ -282,11 +489,139 @@ const getGrandPrizeWinner = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
const PERSON_SELECT = { id: true, firstname: true, lastname: true, email: true };
|
||||
|
||||
// Comptes employés, avec le nombre de lots que chacun a remis. `q` filtre sur
|
||||
// le prénom, le nom ou l'email.
|
||||
const getEmployees = async (req, res) => {
|
||||
try {
|
||||
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
|
||||
const contains = { contains: q, mode: "insensitive" };
|
||||
|
||||
const employees = await prisma.user.findMany({
|
||||
where: {
|
||||
role: "EMPLOYEE",
|
||||
...(q && { OR: [{ firstname: contains }, { lastname: contains }, { email: contains }] }),
|
||||
},
|
||||
select: {
|
||||
...PERSON_SELECT,
|
||||
createdAt: true,
|
||||
mustChangePassword: true,
|
||||
_count: { select: { claimsValidated: { where: { status: "DELIVERED" } } } },
|
||||
},
|
||||
orderBy: [{ lastname: "asc" }, { firstname: "asc" }],
|
||||
});
|
||||
|
||||
return res.json({
|
||||
employees: employees.map(({ _count, ...employee }) => ({
|
||||
...employee,
|
||||
deliveredCount: _count.claimsValidated,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur liste employés:", error);
|
||||
return res.status(500).json({ message: "Erreur serveur lors de la récupération des employés." });
|
||||
}
|
||||
};
|
||||
|
||||
// Lots remis par un employé, du plus récent au plus ancien.
|
||||
const getEmployeeDeliveries = async (req, res) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id)) {
|
||||
return res.status(400).json({ message: "Identifiant d'employé invalide." });
|
||||
}
|
||||
|
||||
const employee = await prisma.user.findFirst({
|
||||
where: { id, role: "EMPLOYEE" },
|
||||
select: PERSON_SELECT,
|
||||
});
|
||||
|
||||
if (!employee) {
|
||||
return res.status(404).json({ message: "Employé introuvable." });
|
||||
}
|
||||
|
||||
const claims = await prisma.claim.findMany({
|
||||
where: { validatedByEmployeeId: id, status: "DELIVERED" },
|
||||
orderBy: { claimedAt: "desc" },
|
||||
include: {
|
||||
participation: {
|
||||
include: {
|
||||
ticket: { select: { code: true } },
|
||||
lot: { select: { name: true, value: true } },
|
||||
user: { select: PERSON_SELECT },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.json({
|
||||
employee,
|
||||
deliveries: claims.map((claim) => ({
|
||||
claimId: claim.id,
|
||||
ticketCode: claim.participation.ticket.code,
|
||||
lot: claim.participation.lot,
|
||||
client: claim.participation.user,
|
||||
deliveredAt: claim.claimedAt,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur remises employé:", error);
|
||||
return res.status(500).json({ message: "Erreur serveur lors de la récupération des remises." });
|
||||
}
|
||||
};
|
||||
|
||||
// Traçabilité d'un ticket : qui l'a joué, quel lot, et quel employé l'a remis
|
||||
// et quand. `status` vaut NOT_PLAYED tant qu'aucun client ne l'a saisi.
|
||||
const getDeliveryByTicketCode = async (req, res) => {
|
||||
try {
|
||||
const code = req.params.code.trim().toUpperCase();
|
||||
const gameWindow = await getGameWindow();
|
||||
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { code },
|
||||
include: {
|
||||
lot: { select: { name: true, value: true } },
|
||||
participation: {
|
||||
include: {
|
||||
user: { select: PERSON_SELECT },
|
||||
claim: { include: { validatedByEmployee: { select: PERSON_SELECT } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
return res.status(404).json({ message: "Aucun ticket ne correspond à ce numéro." });
|
||||
}
|
||||
|
||||
const claim = ticket.participation?.claim;
|
||||
|
||||
return res.json({
|
||||
ticketCode: ticket.code,
|
||||
lot: ticket.lot,
|
||||
status: claim ? effectiveClaimStatus(claim.status, gameWindow) : "NOT_PLAYED",
|
||||
playedAt: ticket.usedAt,
|
||||
client: ticket.participation?.user ?? null,
|
||||
deliveredBy: claim?.validatedByEmployee ?? null,
|
||||
deliveredAt: claim?.status === "DELIVERED" ? claim.claimedAt : null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur recherche remise:", error);
|
||||
return res.status(500).json({ message: "Erreur serveur lors de la recherche du ticket." });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getStats,
|
||||
getUsers,
|
||||
getEmailingContacts,
|
||||
createEmployee,
|
||||
getEmployees,
|
||||
getEmployeeDeliveries,
|
||||
getDeliveryByTicketCode,
|
||||
drawWinner,
|
||||
getGrandPrizeWinner,
|
||||
setGameSettings,
|
||||
getGameSettings,
|
||||
};
|
||||
@@ -3,6 +3,9 @@ const jwt = require("jsonwebtoken");
|
||||
const crypto = require("crypto");
|
||||
const prisma = require("../config/prisma");
|
||||
const transporter = require("../config/mailer");
|
||||
const getFrontendUrl = require("../utils/frontendUrl");
|
||||
const { passwordRuleError } = require("../validations/password.validation");
|
||||
const { normalizeEmail } = require("../validations/email.validation");
|
||||
|
||||
const register = async (req, res) => {
|
||||
try {
|
||||
@@ -34,8 +37,8 @@ const register = async (req, res) => {
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
const emailVerifyToken = crypto.randomBytes(32).toString("hex");
|
||||
const emailVerifyExpires = new Date(Date.now() + 1000 * 60 * 60); // 1h
|
||||
const emailVerifyToken = String(Math.floor(1000 + Math.random() * 9000));
|
||||
const emailVerifyExpires = new Date(Date.now() + 1000 * 60 * 15); // 15 min
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
@@ -62,23 +65,45 @@ const register = async (req, res) => {
|
||||
},
|
||||
});
|
||||
|
||||
const verifyUrl = `${process.env.APP_URL}/api/auth/verify-email/${emailVerifyToken}`;
|
||||
|
||||
// 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: "Validation de votre compte Thé Tip Top",
|
||||
subject: "Votre code de validation Thé Tip Top",
|
||||
html: `
|
||||
<h2>Bienvenue ${firstname}</h2>
|
||||
<p>Merci pour votre inscription.</p>
|
||||
<p>Veuillez cliquer sur le lien ci-dessous pour valider votre compte :</p>
|
||||
<a href="${verifyUrl}">Valider mon compte</a>
|
||||
<p>Ce lien expire dans 1 heure.</p>
|
||||
<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>
|
||||
<p>Voici votre code de validation à 4 chiffres :</p>
|
||||
<div style="font-size:36px;font-weight:bold;letter-spacing:12px;color:#006B6B;text-align:center;padding:20px 0">
|
||||
${emailVerifyToken}
|
||||
</div>
|
||||
<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 email de validation a été envoyé.",
|
||||
message: "Inscription réussie. Un code de validation a été envoyé par email.",
|
||||
user,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -148,12 +173,20 @@ const login = async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
if (!user.isEmailVerified) {
|
||||
if (!user.isEmailVerified && user.role === "CLIENT") {
|
||||
return res.status(403).json({
|
||||
message: "Veuillez valider votre adresse email avant de vous connecter.",
|
||||
});
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -180,6 +213,7 @@ const login = async (req, res) => {
|
||||
lastname: user.lastname,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -190,9 +224,47 @@ const login = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
const RESET_LINK_EXPIRY_MS = 1000 * 60 * 30; // 30 min
|
||||
|
||||
// Enregistre un jeton de réinitialisation (qui remplace le précédent) et envoie
|
||||
// le lien par email. Partagé par « mot de passe oublié » et par le profil.
|
||||
async function sendPasswordResetEmail(user) {
|
||||
const resetPasswordToken = crypto.randomBytes(32).toString("hex");
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
resetPasswordToken,
|
||||
resetPasswordExpires: new Date(Date.now() + RESET_LINK_EXPIRY_MS),
|
||||
},
|
||||
});
|
||||
|
||||
// Le lien doit ouvrir le formulaire du front, pas la route POST de l'API :
|
||||
// un clic depuis un email est une requête GET, qui tombait en 404.
|
||||
const resetUrl = `${getFrontendUrl()}/definir-mot-de-passe/${resetPasswordToken}`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.MAIL_FROM,
|
||||
to: user.email,
|
||||
subject: "Réinitialisation de votre mot de passe Thé Tip Top",
|
||||
html: `
|
||||
<h2>Réinitialisation du mot de passe</h2>
|
||||
<p>Bonjour ${user.firstname},</p>
|
||||
<p>Vous avez demandé à définir un nouveau mot de passe.</p>
|
||||
<p>Cliquez sur le lien ci-dessous pour le choisir :</p>
|
||||
<a href="${resetUrl}">Choisir mon nouveau mot de passe</a>
|
||||
<p>Ce lien expire dans 30 minutes.</p>
|
||||
<p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
const FORGOT_PASSWORD_MESSAGE =
|
||||
"Si un compte existe avec cet email, un lien de réinitialisation vient de lui être envoyé.";
|
||||
|
||||
const forgotPassword = async (req, res) => {
|
||||
try {
|
||||
const { email } = req.body;
|
||||
const email = normalizeEmail(req.body.email);
|
||||
|
||||
if (!email) {
|
||||
return res.status(400).json({
|
||||
@@ -206,42 +278,12 @@ const forgotPassword = async (req, res) => {
|
||||
|
||||
// Sécurité : ne pas dire si l'email existe ou non
|
||||
if (!user) {
|
||||
return res.status(200).json({
|
||||
message: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.",
|
||||
});
|
||||
return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE });
|
||||
}
|
||||
|
||||
const resetPasswordToken = crypto.randomBytes(32).toString("hex");
|
||||
const resetPasswordExpires = new Date(Date.now() + 1000 * 60 * 30); // 30 min
|
||||
await sendPasswordResetEmail(user);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
resetPasswordToken,
|
||||
resetPasswordExpires,
|
||||
},
|
||||
});
|
||||
|
||||
const resetUrl = `${process.env.APP_URL}/api/auth/reset-password/${resetPasswordToken}`;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: process.env.MAIL_FROM,
|
||||
to: user.email,
|
||||
subject: "Réinitialisation de votre mot de passe Thé Tip Top",
|
||||
html: `
|
||||
<h2>Réinitialisation du mot de passe</h2>
|
||||
<p>Bonjour ${user.firstname},</p>
|
||||
<p>Vous avez demandé la réinitialisation de votre mot de passe.</p>
|
||||
<p>Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe :</p>
|
||||
<a href="${resetUrl}">Réinitialiser mon mot de passe</a>
|
||||
<p>Ce lien expire dans 30 minutes.</p>
|
||||
<p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p>
|
||||
`,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
message: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.",
|
||||
});
|
||||
return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE });
|
||||
} catch (error) {
|
||||
console.error("Erreur forgotPassword:", error);
|
||||
return res.status(500).json({
|
||||
@@ -250,14 +292,34 @@ const forgotPassword = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Profil connecté (compte Google sans mot de passe) : même lien, envoyé à
|
||||
// l'adresse du compte. Authentifié, donc sans captcha.
|
||||
const sendPasswordSetupLink = async (req, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
await sendPasswordResetEmail(user);
|
||||
return res.json({ message: `Lien envoyé à ${user.email}.` });
|
||||
} catch (error) {
|
||||
console.error("Erreur sendPasswordSetupLink:", error);
|
||||
return res.status(500).json({ message: "Erreur serveur lors de l'envoi du lien." });
|
||||
}
|
||||
};
|
||||
|
||||
const resetPassword = async (req, res) => {
|
||||
try {
|
||||
const { token } = req.params;
|
||||
const { password } = req.body;
|
||||
const { password, confirmPassword } = req.body;
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
const passwordError = passwordRuleError(password);
|
||||
if (passwordError) {
|
||||
return res.status(400).json({ message: passwordError });
|
||||
}
|
||||
|
||||
// La confirmation est vérifiée côté serveur aussi : le contrôle du
|
||||
// navigateur ne protège pas un appel direct à l'API.
|
||||
if (confirmPassword !== undefined && password !== confirmPassword) {
|
||||
return res.status(400).json({
|
||||
message: "Le nouveau mot de passe doit contenir au moins 8 caractères.",
|
||||
message: "Les deux mots de passe ne correspondent pas.",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -284,6 +346,14 @@ const resetPassword = async (req, res) => {
|
||||
password: hashedPassword,
|
||||
resetPasswordToken: null,
|
||||
resetPasswordExpires: null,
|
||||
// Le lien reçu par email prouve l'accès à la boîte : le mot de passe
|
||||
// choisi ici remplace aussi un éventuel mot de passe provisoire, et
|
||||
// l'adresse est considérée comme validée (sinon la connexion qui suit
|
||||
// serait refusée pour email non vérifié).
|
||||
mustChangePassword: false,
|
||||
isEmailVerified: true,
|
||||
emailVerifyToken: null,
|
||||
emailVerifyExpires: null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -298,17 +368,249 @@ const resetPassword = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
const verifyCode = async (req, res) => {
|
||||
try {
|
||||
const { code } = req.body;
|
||||
const email = normalizeEmail(req.body.email);
|
||||
|
||||
if (!email || !code) {
|
||||
return res.status(400).json({ message: "Email et code requis." });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email } });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: "Compte introuvable." });
|
||||
}
|
||||
|
||||
if (user.isEmailVerified) {
|
||||
return res.status(400).json({ message: "Ce compte est déjà vérifié." });
|
||||
}
|
||||
|
||||
if (!user.emailVerifyToken || user.emailVerifyToken !== String(code)) {
|
||||
return res.status(400).json({ message: "Code invalide." });
|
||||
}
|
||||
|
||||
if (new Date() > user.emailVerifyExpires) {
|
||||
return res.status(400).json({ message: "Code expiré. Veuillez vous réinscrire." });
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { isEmailVerified: true, emailVerifyToken: null, emailVerifyExpires: null },
|
||||
});
|
||||
|
||||
const token = jwt.sign(
|
||||
{ id: user.id, role: user.role },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: "24h" }
|
||||
);
|
||||
|
||||
return res.json({
|
||||
message: "Compte vérifié avec succès.",
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
firstname: user.firstname,
|
||||
lastname: user.lastname,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Erreur verifyCode:", error);
|
||||
return res.status(500).json({ message: "Erreur serveur lors de la vérification." });
|
||||
}
|
||||
};
|
||||
|
||||
// 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 = getFrontendUrl();
|
||||
|
||||
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,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
})
|
||||
).toString("base64");
|
||||
|
||||
// encodeURIComponent : le base64 peut contenir des « + » que le front
|
||||
// relirait comme des espaces dans la query string.
|
||||
return res.redirect(
|
||||
`${frontendUrl}/auth/callback?token=${token}&user=${encodeURIComponent(payload)}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Erreur oauthCallback:", error);
|
||||
return res.redirect(
|
||||
`${frontendUrl}/auth/callback?error=${encodeURIComponent(
|
||||
"Erreur serveur lors de la connexion externe."
|
||||
)}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const me = async (req, res) => {
|
||||
return res.json({
|
||||
user: req.user,
|
||||
});
|
||||
};
|
||||
|
||||
const deleteAccount = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// 1. Supprimer le grand gagnant si c'est lui
|
||||
await tx.grandPrizeWinner.deleteMany({ where: { userId } });
|
||||
|
||||
// 2. Récupérer les participations pour supprimer leurs claims
|
||||
const participations = await tx.participation.findMany({
|
||||
where: { userId },
|
||||
select: { id: true },
|
||||
});
|
||||
const participationIds = participations.map((p) => p.id);
|
||||
|
||||
// 3. Supprimer les claims
|
||||
if (participationIds.length > 0) {
|
||||
await tx.claim.deleteMany({ where: { participationId: { in: participationIds } } });
|
||||
}
|
||||
|
||||
// 4. Supprimer les participations
|
||||
await tx.participation.deleteMany({ where: { userId } });
|
||||
|
||||
// 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 },
|
||||
});
|
||||
|
||||
// 6. Supprimer l'utilisateur
|
||||
await tx.user.delete({ where: { id: userId } });
|
||||
});
|
||||
|
||||
return res.json({ message: "Compte supprimé avec succès." });
|
||||
} catch (error) {
|
||||
console.error("Erreur deleteAccount:", error);
|
||||
return res.status(500).json({ message: "Erreur serveur lors de la suppression du compte." });
|
||||
}
|
||||
};
|
||||
|
||||
// Première connexion d'un employé : remplace le mot de passe provisoire reçu
|
||||
// par email. L'utilisateur vient de s'authentifier avec ce mot de passe, on ne
|
||||
// le redemande donc pas.
|
||||
const setInitialPassword = async (req, res) => {
|
||||
try {
|
||||
const { password, confirmPassword } = req.body;
|
||||
|
||||
if (!req.user.mustChangePassword) {
|
||||
return res.status(400).json({ message: "Votre mot de passe a déjà été défini." });
|
||||
}
|
||||
|
||||
const passwordError = passwordRuleError(password);
|
||||
if (passwordError) {
|
||||
return res.status(400).json({ message: passwordError });
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
return res.status(400).json({ message: "Les deux mots de passe ne correspondent pas." });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||
|
||||
if (user.password && (await bcrypt.compare(password, user.password))) {
|
||||
return res.status(400).json({
|
||||
message: "Choisissez un mot de passe différent du mot de passe provisoire.",
|
||||
});
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(password, 10);
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { password: hashed, mustChangePassword: false },
|
||||
});
|
||||
|
||||
return res.json({ message: "Mot de passe enregistré." });
|
||||
} catch (error) {
|
||||
console.error("Erreur setInitialPassword:", error);
|
||||
return res.status(500).json({ message: "Erreur serveur lors de l'enregistrement du mot de passe." });
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async (req, res) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ message: "Les deux mots de passe sont requis." });
|
||||
}
|
||||
|
||||
const passwordError = passwordRuleError(newPassword);
|
||||
if (passwordError) {
|
||||
return res.status(400).json({ message: passwordError });
|
||||
}
|
||||
|
||||
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." });
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(newPassword, 10);
|
||||
await prisma.user.update({ where: { id: userId }, data: { password: hashed } });
|
||||
|
||||
return res.json({ message: "Mot de passe modifié avec succès." });
|
||||
} catch (error) {
|
||||
console.error("Erreur changePassword:", error);
|
||||
return res.status(500).json({ message: "Erreur serveur lors du changement de mot de passe." });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
register,
|
||||
login,
|
||||
me,
|
||||
verifyEmail,
|
||||
verifyCode,
|
||||
resetPassword,
|
||||
forgotPassword
|
||||
forgotPassword,
|
||||
sendPasswordSetupLink,
|
||||
changePassword,
|
||||
setInitialPassword,
|
||||
deleteAccount,
|
||||
oauthCallback,
|
||||
};
|
||||
@@ -1,9 +1,12 @@
|
||||
const prisma = require("../config/prisma");
|
||||
const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow");
|
||||
|
||||
const getGainByTicketCode = async (req, res) => {
|
||||
try {
|
||||
const { code } = req.params;
|
||||
|
||||
const gameWindow = await getGameWindow();
|
||||
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { code: code.toUpperCase() },
|
||||
include: {
|
||||
@@ -49,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,
|
||||
},
|
||||
});
|
||||
@@ -66,6 +69,8 @@ const deliverClaim = async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const employeeId = req.user.id;
|
||||
|
||||
const gameWindow = await getGameWindow();
|
||||
|
||||
const claim = await prisma.claim.findUnique({
|
||||
where: { id: Number(id) },
|
||||
});
|
||||
@@ -82,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.",
|
||||
});
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
const prisma = require("../config/prisma");
|
||||
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 {
|
||||
const { code } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
const gameWindow = await getGameWindow();
|
||||
|
||||
// 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: participationClosedMessage(gameWindow),
|
||||
phase: gameWindow.phase,
|
||||
});
|
||||
}
|
||||
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
where: { code },
|
||||
include: {
|
||||
@@ -25,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({
|
||||
@@ -59,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,
|
||||
@@ -72,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.",
|
||||
@@ -83,6 +150,8 @@ const myGains = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
const gameWindow = await getGameWindow();
|
||||
|
||||
const participations = await prisma.participation.findMany({
|
||||
where: { userId },
|
||||
include: {
|
||||
@@ -102,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,
|
||||
})),
|
||||
});
|
||||
@@ -114,7 +185,20 @@ const myGains = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Dates publiques du jeu-concours, affichées sur la page d'accueil : elles
|
||||
// suivent la date de début fixée par l'admin.
|
||||
const getPublicGameWindow = async (req, res) => {
|
||||
try {
|
||||
const { configured, phase, startDate, participationDeadline, claimDeadline } = await getGameWindow();
|
||||
return res.json({ configured, phase, startDate, participationDeadline, claimDeadline });
|
||||
} catch (error) {
|
||||
console.error("Erreur lecture dates du jeu:", error);
|
||||
return res.status(500).json({ message: "Erreur serveur lors de la lecture des dates du jeu-concours." });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
participate,
|
||||
myGains,
|
||||
getPublicGameWindow,
|
||||
};
|
||||
@@ -10,7 +10,7 @@ const options = {
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: "http://localhost:5000",
|
||||
url: process.env.API_PUBLIC_URL || "http://localhost:5000",
|
||||
},
|
||||
],
|
||||
components: {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
const prisma = require("../config/prisma");
|
||||
|
||||
// Seules routes accessibles tant qu'un mot de passe provisoire n'a pas été
|
||||
// remplacé : sans ce filtre côté serveur, il suffirait d'appeler l'API
|
||||
// directement pour contourner la page du front.
|
||||
const ALLOWED_WHILE_PASSWORD_CHANGE_REQUIRED = ["/api/auth/me", "/api/auth/initial-password"];
|
||||
|
||||
const authMiddleware = async (req, res, next) => {
|
||||
try {
|
||||
const authHeader = req.headers.authorization;
|
||||
@@ -23,6 +28,9 @@ const authMiddleware = async (req, res, next) => {
|
||||
lastname: true,
|
||||
email: true,
|
||||
role: true,
|
||||
provider: true,
|
||||
password: true,
|
||||
mustChangePassword: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -32,7 +40,19 @@ const authMiddleware = async (req, res, next) => {
|
||||
});
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
const route = `${req.baseUrl}${req.path}`.replace(/\/+$/, "");
|
||||
if (user.mustChangePassword && !ALLOWED_WHILE_PASSWORD_CHANGE_REQUIRED.includes(route)) {
|
||||
return res.status(403).json({
|
||||
message: "Vous devez choisir un nouveau mot de passe avant de continuer.",
|
||||
code: "PASSWORD_CHANGE_REQUIRED",
|
||||
});
|
||||
}
|
||||
|
||||
// req.user est renvoyé tel quel par /api/auth/me : on remplace le hash par
|
||||
// un simple booléen, qui suffit au profil pour proposer « définir » ou
|
||||
// « modifier » son mot de passe.
|
||||
const { password, ...rest } = user;
|
||||
req.user = { ...rest, hasPassword: Boolean(password) };
|
||||
next();
|
||||
} catch (error) {
|
||||
return res.status(401).json({
|
||||
|
||||
34
backend/src/middlewares/captcha.middleware.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
||||
|
||||
// Vérifie le jeton Cloudflare Turnstile envoyé par le front dans
|
||||
// `captchaToken`. Sans TURNSTILE_SECRET_KEY, la vérification est ignorée
|
||||
// (même logique que googleEnabled dans config/passport.js).
|
||||
// À placer AVANT validate() : zod retire les clés inconnues de req.body.
|
||||
const verifyCaptcha = async (req, res, next) => {
|
||||
const secret = process.env.TURNSTILE_SECRET_KEY;
|
||||
if (!secret) return next();
|
||||
|
||||
const token = req.body?.captchaToken;
|
||||
if (!token) {
|
||||
return res.status(400).json({ message: "Veuillez confirmer que vous n'êtes pas un robot." });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(VERIFY_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ secret, response: token, remoteip: req.ip }),
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
return res.status(400).json({ message: "Vérification anti-robot échouée, veuillez réessayer." });
|
||||
}
|
||||
return next();
|
||||
} catch (error) {
|
||||
console.error("Erreur vérification Turnstile :", error);
|
||||
return res.status(503).json({ message: "Service de vérification indisponible, réessayez plus tard." });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = verifyCaptcha;
|
||||
@@ -4,14 +4,19 @@ const {
|
||||
getUsers,
|
||||
getEmailingContacts,
|
||||
createEmployee,
|
||||
getEmployees,
|
||||
getEmployeeDeliveries,
|
||||
getDeliveryByTicketCode,
|
||||
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();
|
||||
|
||||
@@ -79,6 +84,73 @@ router.get("/emailing", getEmailingContacts);
|
||||
*/
|
||||
router.post("/employees", validate(createEmployeeSchema), createEmployee);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/admin/employees:
|
||||
* get:
|
||||
* summary: Liste des comptes employés avec leur nombre de lots remis
|
||||
* tags:
|
||||
* - Admin
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: q
|
||||
* schema:
|
||||
* type: string
|
||||
* description: Filtre sur le prénom, le nom ou l'email
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Employés
|
||||
*/
|
||||
router.get("/employees", getEmployees);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/admin/employees/{id}/deliveries:
|
||||
* get:
|
||||
* summary: Lots remis par un employé
|
||||
* tags:
|
||||
* - Admin
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Employé et liste de ses remises (ticket, lot, client, date)
|
||||
* 404:
|
||||
* description: Employé introuvable
|
||||
*/
|
||||
router.get("/employees/:id/deliveries", getEmployeeDeliveries);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/admin/deliveries/{code}:
|
||||
* get:
|
||||
* summary: Traçabilité d'un ticket (lot, client, employé ayant remis le lot, date)
|
||||
* tags:
|
||||
* - Admin
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: code
|
||||
* required: true
|
||||
* schema:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Statut du ticket (NOT_PLAYED, PENDING, DELIVERED, CANCELLED) et remise éventuelle
|
||||
* 404:
|
||||
* description: Aucun ticket ne correspond à ce numéro
|
||||
*/
|
||||
router.get("/deliveries/:code", getDeliveryByTicketCode);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/admin/draw:
|
||||
@@ -105,10 +177,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;
|
||||
@@ -1,8 +1,11 @@
|
||||
const express = require("express");
|
||||
const { register, login, verifyEmail, forgotPassword, resetPassword, me } = require("../controllers/auth.controller");
|
||||
const { register, login, verifyEmail, verifyCode, forgotPassword, sendPasswordSetupLink, resetPassword, me, changePassword, setInitialPassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller");
|
||||
const authMiddleware = require("../middlewares/auth.middleware");
|
||||
const validate = require("../middlewares/validate.middleware");
|
||||
const verifyCaptcha = require("../middlewares/captcha.middleware");
|
||||
const { registerSchema, loginSchema } = require("../validations/auth.validation");
|
||||
const { passport, googleEnabled, facebookEnabled } = require("../config/passport");
|
||||
const getFrontendUrl = require("../utils/frontendUrl");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -32,7 +35,7 @@ const router = express.Router();
|
||||
* 201:
|
||||
* description: Utilisateur créé
|
||||
*/
|
||||
router.post("/register", validate(registerSchema), register);
|
||||
router.post("/register", verifyCaptcha, validate(registerSchema), register);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -48,7 +51,7 @@ router.post("/register", validate(registerSchema), register);
|
||||
* description: Connexion réussie
|
||||
*/
|
||||
|
||||
router.post("/login", validate(loginSchema), login);
|
||||
router.post("/login", verifyCaptcha, validate(loginSchema), login);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -110,16 +113,36 @@ router.get("/verify-email/:token",verifyEmail);
|
||||
* type: string
|
||||
* format: email
|
||||
* example: jean.dupont@gmail.com
|
||||
* captchaToken:
|
||||
* type: string
|
||||
* description: Jeton Cloudflare Turnstile (exigé si TURNSTILE_SECRET_KEY est configurée)
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.
|
||||
* 400:
|
||||
* description: Email requis.
|
||||
* description: Email requis, ou vérification anti-robot manquante.
|
||||
* 500:
|
||||
* description: Erreur serveur.
|
||||
*/
|
||||
|
||||
router.post("/forgot-password", forgotPassword);
|
||||
// Page publique : le captcha empêche d'envoyer des emails en masse vers
|
||||
// n'importe quelle adresse.
|
||||
router.post("/forgot-password", verifyCaptcha, forgotPassword);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/auth/password-setup-link:
|
||||
* post:
|
||||
* summary: Envoie à l'utilisateur connecté un lien pour définir son mot de passe
|
||||
* tags:
|
||||
* - Auth
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Lien envoyé à l'adresse du compte.
|
||||
*/
|
||||
router.post("/password-setup-link", authMiddleware, sendPasswordSetupLink);
|
||||
/**
|
||||
* @swagger
|
||||
* /api/auth/reset-password/{token}:
|
||||
@@ -157,5 +180,133 @@ router.post("/forgot-password", forgotPassword);
|
||||
*/
|
||||
router.post("/reset-password/:token", resetPassword);
|
||||
|
||||
router.post("/verify-code", verifyCode);
|
||||
router.patch("/change-password", authMiddleware, changePassword);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/auth/initial-password:
|
||||
* post:
|
||||
* summary: Remplace le mot de passe provisoire d'un employé à sa première connexion
|
||||
* tags:
|
||||
* - Auth
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - password
|
||||
* - confirmPassword
|
||||
* properties:
|
||||
* password:
|
||||
* type: string
|
||||
* confirmPassword:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Mot de passe enregistré, l'accès complet est rétabli.
|
||||
* 400:
|
||||
* description: Mot de passe invalide, non confirmé, identique au provisoire ou déjà défini.
|
||||
*/
|
||||
router.post("/initial-password", authMiddleware, setInitialPassword);
|
||||
router.delete("/account", authMiddleware, deleteAccount);
|
||||
|
||||
/**
|
||||
* @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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/auth/facebook:
|
||||
* get:
|
||||
* summary: Démarre la connexion via Facebook (redirection OAuth 2.0)
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 302:
|
||||
* description: Redirection vers l'écran de consentement Facebook.
|
||||
* 503:
|
||||
* description: Connexion Facebook non configurée sur ce serveur.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/auth/facebook/callback:
|
||||
* get:
|
||||
* summary: Retour de Facebook, émet le JWT et redirige vers le front
|
||||
* tags:
|
||||
* - Auth
|
||||
* responses:
|
||||
* 302:
|
||||
* description: Redirection vers FRONTEND_URL/auth/callback avec le token.
|
||||
*/
|
||||
|
||||
function mountOAuthRoutes({ path, strategy, label, enabled, scope }) {
|
||||
if (!enabled) {
|
||||
// Sans identifiants configurés, on répond explicitement plutôt que de
|
||||
// laisser passport planter au démarrage.
|
||||
const notConfigured = (req, res) =>
|
||||
res.status(503).json({ message: `La connexion ${label} n'est pas configurée sur ce serveur.` });
|
||||
|
||||
router.get(path, notConfigured);
|
||||
router.get(`${path}/callback`, notConfigured);
|
||||
return;
|
||||
}
|
||||
|
||||
router.get(path, passport.authenticate(strategy, { scope, session: false }));
|
||||
|
||||
router.get(
|
||||
`${path}/callback`,
|
||||
passport.authenticate(strategy, {
|
||||
session: false,
|
||||
failureRedirect: `${getFrontendUrl()}/auth/callback?error=${encodeURIComponent(
|
||||
`Connexion ${label} refusée.`
|
||||
)}`,
|
||||
}),
|
||||
oauthCallback
|
||||
);
|
||||
}
|
||||
|
||||
mountOAuthRoutes({
|
||||
path: "/google",
|
||||
strategy: "google",
|
||||
label: "Google",
|
||||
enabled: googleEnabled,
|
||||
scope: ["profile", "email"],
|
||||
});
|
||||
|
||||
mountOAuthRoutes({
|
||||
path: "/facebook",
|
||||
strategy: "facebook",
|
||||
label: "Facebook",
|
||||
enabled: facebookEnabled,
|
||||
scope: ["email"],
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -36,7 +36,7 @@ const router = express.Router();
|
||||
router.get(
|
||||
"/ticket/:code",
|
||||
authMiddleware,
|
||||
roleMiddleware("EMPLOYEE", "ADMIN"),
|
||||
roleMiddleware("EMPLOYEE"),
|
||||
getGainByTicketCode
|
||||
);
|
||||
|
||||
@@ -69,7 +69,7 @@ router.get(
|
||||
router.patch(
|
||||
"/claim/:id/deliver",
|
||||
authMiddleware,
|
||||
roleMiddleware("EMPLOYEE", "ADMIN"),
|
||||
roleMiddleware("EMPLOYEE"),
|
||||
deliverClaim
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const express = require("express");
|
||||
const { participate, myGains } = require("../controllers/game.controller");
|
||||
const { participate, myGains, getPublicGameWindow } = require("../controllers/game.controller");
|
||||
const authMiddleware = require("../middlewares/auth.middleware");
|
||||
const roleMiddleware = require("../middlewares/role.middleware");
|
||||
const validate = require("../middlewares/validate.middleware");
|
||||
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,19 @@ 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);
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/game/window:
|
||||
* get:
|
||||
* summary: Dates publiques du jeu-concours (début, fin des participations, fin des retraits)
|
||||
* tags:
|
||||
* - Game
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Phase et échéances courantes ; dates nulles si le jeu n'est pas configuré
|
||||
*/
|
||||
router.get("/window", getPublicGameWindow);
|
||||
|
||||
module.exports = router;
|
||||
45
backend/src/scripts/createAdmin.js
Normal file
@@ -0,0 +1,45 @@
|
||||
require('dotenv').config()
|
||||
|
||||
const bcrypt = require('bcrypt')
|
||||
const prisma = require('../config/prisma')
|
||||
|
||||
async function createAdmin() {
|
||||
const email = process.env.ADMIN_EMAIL
|
||||
const password = process.env.ADMIN_PASSWORD
|
||||
|
||||
if (!email || !password) {
|
||||
throw new Error('ADMIN_EMAIL et ADMIN_PASSWORD doivent être définis')
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email } })
|
||||
|
||||
if (existing) {
|
||||
await prisma.user.update({
|
||||
where: { email },
|
||||
data: { isEmailVerified: true, role: 'ADMIN' },
|
||||
})
|
||||
console.log('Admin mis à jour :', email)
|
||||
return
|
||||
}
|
||||
|
||||
const hashed = await bcrypt.hash(password, 10)
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
firstname: 'Admin',
|
||||
lastname: 'TipTop',
|
||||
email,
|
||||
password: hashed,
|
||||
role: 'ADMIN',
|
||||
isEmailVerified: true,
|
||||
},
|
||||
})
|
||||
|
||||
console.log('Admin créé avec succès')
|
||||
console.log(' Email :', email)
|
||||
console.log(' Mot de passe:', password)
|
||||
}
|
||||
|
||||
createAdmin()
|
||||
.catch((e) => { console.error('Erreur :', e.message); process.exit(1) })
|
||||
.finally(() => prisma.$disconnect())
|
||||
9
backend/src/utils/frontendUrl.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// FRONTEND_URL peut lister plusieurs origines séparées par des virgules (prod :
|
||||
// domaine nu + www), ce qui sert à CORS dans app.js. Pour construire un lien
|
||||
// (email, redirection OAuth), on ne garde que la première, l'URL canonique.
|
||||
function getFrontendUrl() {
|
||||
const first = (process.env.FRONTEND_URL || "").split(",")[0].trim();
|
||||
return (first || "http://localhost:5173").replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
module.exports = getFrontendUrl;
|
||||
106
backend/src/utils/gameWindow.js
Normal file
@@ -0,0 +1,106 @@
|
||||
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",
|
||||
};
|
||||
|
||||
// Date de lancement prévue par le règlement : du 2 au 31 octobre 2026 (heure
|
||||
// de Paris). Utilisée pour initialiser la base ; l'admin peut la corriger
|
||||
// depuis son tableau de bord.
|
||||
const DEFAULT_START_DATE = new Date("2026-10-02T00:00:00+02:00");
|
||||
|
||||
const GAME_DURATION_DAYS = 30;
|
||||
const CLAIM_EXTENSION_DAYS = 30;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
// 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,
|
||||
isParticipationOpen: false,
|
||||
isOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
const participationDeadline = new Date(settings.startDate.getTime() + GAME_DURATION_DAYS * DAY_MS);
|
||||
const claimDeadline = new Date(
|
||||
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,
|
||||
hasStarted,
|
||||
isParticipationOpen,
|
||||
isOpen,
|
||||
};
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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,
|
||||
DEFAULT_START_DATE,
|
||||
GAME_DURATION_DAYS,
|
||||
CLAIM_EXTENSION_DAYS,
|
||||
};
|
||||
@@ -1,14 +1,22 @@
|
||||
const { z } = require("zod");
|
||||
const { emailSchema } = require("./email.validation");
|
||||
|
||||
const createEmployeeSchema = z.object({
|
||||
firstname: z.string().min(2),
|
||||
lastname: z.string().min(2),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
// Pas de mot de passe : il est généré côté serveur (voir createEmployee).
|
||||
email: emailSchema,
|
||||
birthdate: z.string().optional(),
|
||||
gender: z.string().optional(),
|
||||
});
|
||||
|
||||
const gameSettingsSchema = z.object({
|
||||
startDate: z
|
||||
.string()
|
||||
.refine((value) => !Number.isNaN(new Date(value).getTime()), "Date de début invalide"),
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
createEmployeeSchema,
|
||||
gameSettingsSchema,
|
||||
};
|
||||
@@ -1,17 +1,27 @@
|
||||
const { z } = require("zod");
|
||||
const { passwordSchema } = require("./password.validation");
|
||||
const { emailSchema } = require("./email.validation");
|
||||
|
||||
const registerSchema = z.object({
|
||||
const registerSchema = z
|
||||
.object({
|
||||
firstname: z.string().min(2, "Le prénom doit contenir au moins 2 caractères"),
|
||||
lastname: z.string().min(2, "Le nom doit contenir au moins 2 caractères"),
|
||||
email: z.string().email("Email invalide"),
|
||||
password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"),
|
||||
email: emailSchema,
|
||||
password: passwordSchema,
|
||||
// Vérifiée aussi côté serveur : le contrôle du navigateur ne protège pas
|
||||
// un appel direct à l'API.
|
||||
confirmPassword: z.string({ error: "Veuillez confirmer le mot de passe" }),
|
||||
birthdate: z.string().optional(),
|
||||
gender: z.string().optional(),
|
||||
marketingOptIn: z.boolean().optional(),
|
||||
})
|
||||
.refine((data) => data.password === data.confirmPassword, {
|
||||
message: "Les deux mots de passe ne correspondent pas",
|
||||
path: ["confirmPassword"],
|
||||
});
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email("Email invalide"),
|
||||
email: emailSchema,
|
||||
password: z.string().min(1, "Mot de passe requis"),
|
||||
});
|
||||
|
||||
|
||||
11
backend/src/validations/email.validation.js
Normal file
@@ -0,0 +1,11 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
// Les emails sont stockés et recherchés en minuscules, sans espaces autour :
|
||||
// « Jean@Exemple.fr » et « jean@exemple.fr » désignent le même compte.
|
||||
function normalizeEmail(email) {
|
||||
return typeof email === "string" ? email.trim().toLowerCase() : email;
|
||||
}
|
||||
|
||||
const emailSchema = z.string().trim().toLowerCase().email("Email invalide");
|
||||
|
||||
module.exports = { emailSchema, normalizeEmail };
|
||||
21
backend/src/validations/password.validation.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const { z } = require("zod");
|
||||
|
||||
// Règle de mot de passe, reprise à l'identique par le front
|
||||
// (frontend/src/utils/passwordRules.js) : plus de 7 caractères et au moins un
|
||||
// caractère spécial, c'est-à-dire ni lettre (accentuée comprise), ni chiffre,
|
||||
// ni espace.
|
||||
const SPECIAL_CHARACTER = /[^\p{L}\p{N}\s]/u;
|
||||
|
||||
const passwordSchema = z
|
||||
.string()
|
||||
.min(8, "Le mot de passe doit contenir au moins 8 caractères")
|
||||
.regex(SPECIAL_CHARACTER, "Le mot de passe doit contenir au moins un caractère spécial");
|
||||
|
||||
// Pour les contrôleurs qui valident à la main : message de la première règle
|
||||
// non respectée, ou null si le mot de passe est valide.
|
||||
function passwordRuleError(password) {
|
||||
const result = passwordSchema.safeParse(password ?? "");
|
||||
return result.success ? null : `${result.error.issues[0].message}.`;
|
||||
}
|
||||
|
||||
module.exports = { passwordSchema, passwordRuleError, SPECIAL_CHARACTER };
|
||||
144
backend/tests/adminDeliveries.test.js
Normal file
@@ -0,0 +1,144 @@
|
||||
const request = require("supertest");
|
||||
const bcrypt = require("bcrypt");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const app = require("../src/app");
|
||||
const prisma = require("../src/config/prisma");
|
||||
|
||||
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) }));
|
||||
|
||||
const PREFIX = "tracabilite-";
|
||||
const CODES = ["TRACE00001", "TRACE00002", "TRACE00003", "TRACE00004"];
|
||||
const ctx = {};
|
||||
|
||||
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||
const asAdmin = (req) => req.set("Authorization", `Bearer ${ctx.tAdmin}`);
|
||||
|
||||
async function cleanup() {
|
||||
const users = await prisma.user.findMany({ where: { email: { startsWith: PREFIX } } });
|
||||
const ids = users.map((u) => u.id);
|
||||
const participations = await prisma.participation.findMany({ where: { userId: { in: ids } } });
|
||||
await prisma.claim.deleteMany({ where: { participationId: { in: participations.map((p) => p.id) } } });
|
||||
await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
|
||||
await prisma.ticket.deleteMany({ where: { code: { in: CODES } } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
|
||||
// Ticket joué par le client, avec une réclamation éventuellement remise.
|
||||
async function jouer(code, { remisPar, remisLe } = {}) {
|
||||
const ticket = await prisma.ticket.create({
|
||||
data: { code, lotId: ctx.lot.id, isUsed: true, usedAt: new Date("2026-09-01T10:00:00Z"), userId: ctx.client.id },
|
||||
});
|
||||
const participation = await prisma.participation.create({
|
||||
data: { userId: ctx.client.id, ticketId: ticket.id, lotId: ctx.lot.id },
|
||||
});
|
||||
return prisma.claim.create({
|
||||
data: {
|
||||
participationId: participation.id,
|
||||
status: remisPar ? "DELIVERED" : "PENDING",
|
||||
validatedByEmployeeId: remisPar?.id ?? null,
|
||||
claimedAt: remisLe ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
const h = await bcrypt.hash("Password123", 10);
|
||||
const mk = (email, role, firstname, lastname, extra = {}) =>
|
||||
prisma.user.create({ data: { firstname, lastname, email: PREFIX + email, password: h, role, isEmailVerified: true, ...extra } });
|
||||
|
||||
ctx.admin = await mk("admin@test.local", "ADMIN", "Alice", "Admin");
|
||||
ctx.marc = await mk("marc@test.local", "EMPLOYEE", "Marc", "Durand");
|
||||
ctx.sophie = await mk("sophie@test.local", "EMPLOYEE", "Sophie", "Bernard", { mustChangePassword: true });
|
||||
ctx.client = await mk("client@test.local", "CLIENT", "Chloé", "Martin");
|
||||
ctx.tAdmin = tk(ctx.admin);
|
||||
ctx.lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
|
||||
|
||||
await jouer(CODES[0], { remisPar: ctx.marc, remisLe: new Date("2026-09-10T14:30:00Z") });
|
||||
await jouer(CODES[1], { remisPar: ctx.marc, remisLe: new Date("2026-09-12T09:15:00Z") });
|
||||
await jouer(CODES[2]);
|
||||
await prisma.ticket.create({ data: { code: CODES[3], lotId: ctx.lot.id } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("Liste des employes", () => {
|
||||
test("renvoie les employes avec leur nombre de lots remis", async () => {
|
||||
const res = await asAdmin(request(app).get("/api/admin/employees")).expect(200);
|
||||
|
||||
const marc = res.body.employees.find((e) => e.id === ctx.marc.id);
|
||||
const sophie = res.body.employees.find((e) => e.id === ctx.sophie.id);
|
||||
expect(marc.deliveredCount).toBe(2);
|
||||
expect(sophie.deliveredCount).toBe(0);
|
||||
expect(sophie.mustChangePassword).toBe(true);
|
||||
// Ni client, ni admin, ni hash de mot de passe.
|
||||
expect(res.body.employees.every((e) => e.id !== ctx.client.id && e.id !== ctx.admin.id)).toBe(true);
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/"password"/);
|
||||
});
|
||||
|
||||
test("filtre par nom ou email avec q", async () => {
|
||||
const res = await asAdmin(request(app).get("/api/admin/employees?q=BERNARD")).expect(200);
|
||||
expect(res.body.employees.map((e) => e.id)).toEqual([ctx.sophie.id]);
|
||||
});
|
||||
|
||||
test("est reserve a l admin", async () => {
|
||||
await request(app).get("/api/admin/employees").set("Authorization", `Bearer ${tk(ctx.marc)}`).expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Lots remis par un employe", () => {
|
||||
test("liste les remises de l employe, de la plus recente a la plus ancienne", async () => {
|
||||
const res = await asAdmin(request(app).get(`/api/admin/employees/${ctx.marc.id}/deliveries`)).expect(200);
|
||||
|
||||
expect(res.body.employee.email).toBe(PREFIX + "marc@test.local");
|
||||
expect(res.body.deliveries.map((d) => d.ticketCode)).toEqual([CODES[1], CODES[0]]);
|
||||
expect(res.body.deliveries[0].client.firstname).toBe("Chloé");
|
||||
expect(res.body.deliveries[0].lot.name).toBe(ctx.lot.name);
|
||||
expect(new Date(res.body.deliveries[0].deliveredAt).toISOString()).toBe("2026-09-12T09:15:00.000Z");
|
||||
});
|
||||
|
||||
test("renvoie une liste vide pour un employe sans remise", async () => {
|
||||
const res = await asAdmin(request(app).get(`/api/admin/employees/${ctx.sophie.id}/deliveries`)).expect(200);
|
||||
expect(res.body.deliveries).toEqual([]);
|
||||
});
|
||||
|
||||
test("refuse un identifiant qui n est pas un employe", async () => {
|
||||
await asAdmin(request(app).get(`/api/admin/employees/${ctx.client.id}/deliveries`)).expect(404);
|
||||
await asAdmin(request(app).get("/api/admin/employees/abc/deliveries")).expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Recherche par numero de ticket", () => {
|
||||
test("indique l employe qui a remis le lot et la date, sans tenir compte de la casse", async () => {
|
||||
const res = await asAdmin(request(app).get(`/api/admin/deliveries/${CODES[0].toLowerCase()}`)).expect(200);
|
||||
|
||||
expect(res.body.ticketCode).toBe(CODES[0]);
|
||||
expect(res.body.status).toBe("DELIVERED");
|
||||
expect(res.body.deliveredBy).toMatchObject({ id: ctx.marc.id, firstname: "Marc", lastname: "Durand" });
|
||||
expect(new Date(res.body.deliveredAt).toISOString()).toBe("2026-09-10T14:30:00.000Z");
|
||||
expect(res.body.client.email).toBe(PREFIX + "client@test.local");
|
||||
});
|
||||
|
||||
test("un lot gagne mais pas encore remis n a pas d employe", async () => {
|
||||
const res = await asAdmin(request(app).get(`/api/admin/deliveries/${CODES[2]}`)).expect(200);
|
||||
|
||||
// PENDING, ou CANCELLED si la période de retrait du jeu en base est passée.
|
||||
expect(["PENDING", "CANCELLED"]).toContain(res.body.status);
|
||||
expect(res.body.deliveredBy).toBeNull();
|
||||
expect(res.body.deliveredAt).toBeNull();
|
||||
});
|
||||
|
||||
test("un ticket jamais joue est signale comme tel", async () => {
|
||||
const res = await asAdmin(request(app).get(`/api/admin/deliveries/${CODES[3]}`)).expect(200);
|
||||
|
||||
expect(res.body.status).toBe("NOT_PLAYED");
|
||||
expect(res.body.client).toBeNull();
|
||||
});
|
||||
|
||||
test("un numero inconnu renvoie 404", async () => {
|
||||
await asAdmin(request(app).get("/api/admin/deliveries/INEXISTANT")).expect(404);
|
||||
});
|
||||
});
|
||||
75
backend/tests/captcha.middleware.test.js
Normal file
@@ -0,0 +1,75 @@
|
||||
const verifyCaptcha = require("../src/middlewares/captcha.middleware");
|
||||
|
||||
function mockRes() {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
describe("verifyCaptcha", () => {
|
||||
const originalFetch = global.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.TURNSTILE_SECRET_KEY = "secret-test";
|
||||
global.fetch = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.TURNSTILE_SECRET_KEY = "";
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
it("laisse passer sans vérifier quand aucune clé n'est configurée", async () => {
|
||||
process.env.TURNSTILE_SECRET_KEY = "";
|
||||
const next = jest.fn();
|
||||
|
||||
await verifyCaptcha({ body: {} }, mockRes(), next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(global.fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refuse une requête sans jeton", async () => {
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await verifyCaptcha({ body: {} }, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appelle next quand Cloudflare valide le jeton", async () => {
|
||||
global.fetch.mockResolvedValue({ json: () => Promise.resolve({ success: true }) });
|
||||
const next = jest.fn();
|
||||
|
||||
await verifyCaptcha({ body: { captchaToken: "jeton" }, ip: "1.2.3.4" }, mockRes(), next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
const [, options] = global.fetch.mock.calls[0];
|
||||
expect(JSON.parse(options.body)).toEqual({ secret: "secret-test", response: "jeton", remoteip: "1.2.3.4" });
|
||||
});
|
||||
|
||||
it("refuse quand Cloudflare rejette le jeton", async () => {
|
||||
global.fetch.mockResolvedValue({ json: () => Promise.resolve({ success: false }) });
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await verifyCaptcha({ body: { captchaToken: "faux" } }, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("répond 503 quand Cloudflare est injoignable", async () => {
|
||||
global.fetch.mockRejectedValue(new Error("réseau"));
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
const res = mockRes();
|
||||
|
||||
await verifyCaptcha({ body: { captchaToken: "jeton" } }, res, jest.fn());
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(503);
|
||||
console.error.mockRestore();
|
||||
});
|
||||
});
|
||||
87
backend/tests/draw.test.js
Normal 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()
|
||||
);
|
||||
});
|
||||
134
backend/tests/drawNotification.test.js
Normal file
@@ -0,0 +1,134 @@
|
||||
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) },
|
||||
});
|
||||
|
||||
|
||||
// Un tirage reel peut exister en base : la table GrandPrizeWinner est unique
|
||||
// pour tout le jeu. On la met de cote le temps du test, puis on la restaure.
|
||||
async function mettreDeCoteLeGagnant() {
|
||||
const existant = await prisma.grandPrizeWinner.findFirst();
|
||||
if (existant) await prisma.grandPrizeWinner.delete({ where: { id: existant.id } });
|
||||
return existant;
|
||||
}
|
||||
|
||||
async function restaurerLeGagnant(sauvegarde) {
|
||||
if (!sauvegarde) return;
|
||||
await prisma.grandPrizeWinner.deleteMany({});
|
||||
await prisma.grandPrizeWinner.create({
|
||||
data: {
|
||||
id: sauvegarde.id,
|
||||
userId: sauvegarde.userId,
|
||||
prize: sauvegarde.prize,
|
||||
drawnAt: sauvegarde.drawnAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant();
|
||||
await cleanup();
|
||||
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||
ctx.settingsId = row.id;
|
||||
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 restaurerLeGagnant(ctx.gagnantSauvegarde);
|
||||
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);
|
||||
|
||||
// Le tirage est aleatoire parmi tous les participants : on verifie que
|
||||
// l email part bien au gagnant annonce, quel qu il soit.
|
||||
const gagnant = res.body.winner.user;
|
||||
const envoi = mailer.sendMail.mock.calls[0][0];
|
||||
expect(envoi.to).toBe(gagnant.email);
|
||||
expect(envoi.subject).toMatch(/gros lot/i);
|
||||
expect(envoi.html).toContain(gagnant.firstname);
|
||||
expect(envoi.html).toContain("360");
|
||||
|
||||
await prisma.grandPrizeWinner.deleteMany({});
|
||||
});
|
||||
|
||||
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();
|
||||
expect(enBase).not.toBeNull();
|
||||
expect(enBase.userId).toBe(res.body.winner.user.id);
|
||||
|
||||
await prisma.grandPrizeWinner.deleteMany({});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -10,54 +10,78 @@ 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;
|
||||
let settingsBackup;
|
||||
|
||||
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 } });
|
||||
|
||||
// Le parcours a besoin d'un jeu ouvert, quelle que soit la date de début
|
||||
// enregistrée en base (par défaut le 2 octobre 2026) : on la place à hier
|
||||
// le temps du test, puis on la restaure.
|
||||
settingsBackup = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||
const hier = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
if (settingsBackup) {
|
||||
await prisma.gameSettings.update({ where: { id: settingsBackup.id }, data: { startDate: hier } });
|
||||
} else {
|
||||
await prisma.gameSettings.create({ data: { startDate: hier } });
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (settingsBackup) {
|
||||
await prisma.gameSettings.update({ where: { id: settingsBackup.id }, data: { startDate: settingsBackup.startDate } });
|
||||
} else {
|
||||
await prisma.gameSettings.deleteMany({});
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { email: testEmail } });
|
||||
if (user) {
|
||||
await prisma.claim.deleteMany({ where: { participation: { userId: user.id } } });
|
||||
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!",
|
||||
confirmPassword: "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);
|
||||
@@ -65,70 +89,28 @@ describe("THE TIP TOP - FULL E2E FLOW", () => {
|
||||
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 +118,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")
|
||||
|
||||
166
backend/tests/employeeOnboarding.test.js
Normal file
@@ -0,0 +1,166 @@
|
||||
const request = require("supertest");
|
||||
const bcrypt = require("bcrypt");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const app = require("../src/app");
|
||||
const prisma = require("../src/config/prisma");
|
||||
const mailer = require("../src/config/mailer");
|
||||
const { passwordRuleError } = require("../src/validations/password.validation");
|
||||
|
||||
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
|
||||
|
||||
const PREFIX = "onboarding-";
|
||||
const ADMIN_EMAIL = PREFIX + "admin@test.local";
|
||||
const EMPLOYEE_EMAIL = PREFIX + "employe@test.local";
|
||||
const ctx = {};
|
||||
|
||||
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||
|
||||
// Récupère le mot de passe provisoire dans l'email envoyé à l'employé.
|
||||
function tempPasswordFromMail() {
|
||||
const html = mailer.sendMail.mock.calls[0][0].html;
|
||||
return html.match(/Mot de passe provisoire : <strong[^>]*>([^<]+)<\/strong>/)[1];
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.user.deleteMany({ where: { email: { startsWith: PREFIX } } });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
const h = await bcrypt.hash("Password123", 10);
|
||||
ctx.admin = await prisma.user.create({
|
||||
data: { firstname: "Alice", lastname: "Admin", email: ADMIN_EMAIL, password: h, role: "ADMIN", isEmailVerified: true },
|
||||
});
|
||||
ctx.tAdmin = tk(ctx.admin);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("Creation d un employe avec mot de passe provisoire", () => {
|
||||
test("un echec d envoi de l email annule la creation du compte", async () => {
|
||||
mailer.sendMail.mockRejectedValueOnce(new Error("SMTP indisponible"));
|
||||
jest.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/admin/employees")
|
||||
.set("Authorization", `Bearer ${ctx.tAdmin}`)
|
||||
.send({ firstname: "Marc", lastname: "Durand", email: EMPLOYEE_EMAIL });
|
||||
|
||||
console.error.mockRestore();
|
||||
expect(res.status).toBe(502);
|
||||
expect(await prisma.user.findUnique({ where: { email: EMPLOYEE_EMAIL } })).toBeNull();
|
||||
});
|
||||
|
||||
test("l admin cree l employe sans mot de passe, un mot de passe provisoire est envoye", async () => {
|
||||
mailer.sendMail.mockClear();
|
||||
mailer.sendMail.mockResolvedValue(true);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/admin/employees")
|
||||
.set("Authorization", `Bearer ${ctx.tAdmin}`)
|
||||
// Un mot de passe choisi par l'admin est ignoré.
|
||||
.send({ firstname: "Marc", lastname: "Durand", email: EMPLOYEE_EMAIL, password: "ChoixAdmin123" });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.employee.role).toBe("EMPLOYEE");
|
||||
// Le mot de passe ne transite jamais par la réponse faite à l'admin.
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/password/i);
|
||||
|
||||
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||
expect(mailer.sendMail.mock.calls[0][0].to).toBe(EMPLOYEE_EMAIL);
|
||||
ctx.tempPassword = tempPasswordFromMail();
|
||||
expect(ctx.tempPassword).toHaveLength(12);
|
||||
expect(ctx.tempPassword).not.toBe("ChoixAdmin123");
|
||||
// Le provisoire respecte lui aussi la règle commune.
|
||||
expect(passwordRuleError(ctx.tempPassword)).toBeNull();
|
||||
|
||||
const enBase = await prisma.user.findUnique({ where: { email: EMPLOYEE_EMAIL } });
|
||||
expect(enBase.mustChangePassword).toBe(true);
|
||||
expect(await bcrypt.compare("ChoixAdmin123", enBase.password)).toBe(false);
|
||||
});
|
||||
|
||||
test("la connexion avec le mot de passe provisoire signale le changement obligatoire", async () => {
|
||||
const res = await request(app).post("/api/auth/login").send({ email: EMPLOYEE_EMAIL, password: ctx.tempPassword });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.user.mustChangePassword).toBe(true);
|
||||
ctx.tEmployee = res.body.token;
|
||||
});
|
||||
|
||||
test("tant que le mot de passe n est pas change, l API est bloquee sauf /me", async () => {
|
||||
const bloque = await request(app).get("/api/employee/ticket/INCONNU").set("Authorization", `Bearer ${ctx.tEmployee}`);
|
||||
expect(bloque.status).toBe(403);
|
||||
expect(bloque.body.code).toBe("PASSWORD_CHANGE_REQUIRED");
|
||||
|
||||
const changement = await request(app)
|
||||
.patch("/api/auth/change-password")
|
||||
.set("Authorization", `Bearer ${ctx.tEmployee}`)
|
||||
.send({ currentPassword: ctx.tempPassword, newPassword: "Contournement1" });
|
||||
expect(changement.status).toBe(403);
|
||||
|
||||
const me = await request(app).get("/api/auth/me").set("Authorization", `Bearer ${ctx.tEmployee}`);
|
||||
expect(me.status).toBe(200);
|
||||
expect(me.body.user.mustChangePassword).toBe(true);
|
||||
});
|
||||
|
||||
test("le nouveau mot de passe doit etre confirme et different du provisoire", async () => {
|
||||
const auth = { Authorization: `Bearer ${ctx.tEmployee}` };
|
||||
|
||||
const different = await request(app)
|
||||
.post("/api/auth/initial-password")
|
||||
.set(auth)
|
||||
.send({ password: "NouveauMdp123!", confirmPassword: "AutreMdp456!" });
|
||||
expect(different.status).toBe(400);
|
||||
expect(different.body.message).toMatch(/ne correspondent pas/);
|
||||
|
||||
const court = await request(app)
|
||||
.post("/api/auth/initial-password")
|
||||
.set(auth)
|
||||
.send({ password: "court", confirmPassword: "court" });
|
||||
expect(court.status).toBe(400);
|
||||
|
||||
const sansSpecial = await request(app)
|
||||
.post("/api/auth/initial-password")
|
||||
.set(auth)
|
||||
.send({ password: "NouveauMdp123", confirmPassword: "NouveauMdp123" });
|
||||
expect(sansSpecial.status).toBe(400);
|
||||
expect(sansSpecial.body.message).toMatch(/caractère spécial/);
|
||||
|
||||
const identique = await request(app)
|
||||
.post("/api/auth/initial-password")
|
||||
.set(auth)
|
||||
.send({ password: ctx.tempPassword, confirmPassword: ctx.tempPassword });
|
||||
expect(identique.status).toBe(400);
|
||||
expect(identique.body.message).toMatch(/provisoire/);
|
||||
});
|
||||
|
||||
test("apres le changement, l acces est retabli et seul le nouveau mot de passe fonctionne", async () => {
|
||||
const auth = { Authorization: `Bearer ${ctx.tEmployee}` };
|
||||
|
||||
const ok = await request(app)
|
||||
.post("/api/auth/initial-password")
|
||||
.set(auth)
|
||||
.send({ password: "NouveauMdp123!", confirmPassword: "NouveauMdp123!" });
|
||||
expect(ok.status).toBe(200);
|
||||
|
||||
// Le ticket n'existe pas : un 404 prouve que la requête a franchi le filtre.
|
||||
const acces = await request(app).get("/api/employee/ticket/INCONNU").set(auth);
|
||||
expect(acces.status).not.toBe(403);
|
||||
|
||||
const encore = await request(app)
|
||||
.post("/api/auth/initial-password")
|
||||
.set(auth)
|
||||
.send({ password: "EncoreUnAutre1!", confirmPassword: "EncoreUnAutre1!" });
|
||||
expect(encore.status).toBe(400);
|
||||
|
||||
const ancien = await request(app).post("/api/auth/login").send({ email: EMPLOYEE_EMAIL, password: ctx.tempPassword });
|
||||
expect(ancien.status).toBe(401);
|
||||
|
||||
const nouveau = await request(app).post("/api/auth/login").send({ email: EMPLOYEE_EMAIL, password: "NouveauMdp123!" });
|
||||
expect(nouveau.status).toBe(200);
|
||||
expect(nouveau.body.user.mustChangePassword).toBe(false);
|
||||
});
|
||||
});
|
||||
113
backend/tests/facebookLogin.test.js
Normal file
@@ -0,0 +1,113 @@
|
||||
// Identifiants factices posés avant le chargement de passport : la stratégie
|
||||
// Facebook est ainsi enregistrée même sans vraie app Meta configurée.
|
||||
process.env.FACEBOOK_APP_ID = "test-app-id";
|
||||
process.env.FACEBOOK_APP_SECRET = "test-app-secret";
|
||||
|
||||
const request = require("supertest");
|
||||
const app = require("../src/app");
|
||||
const prisma = require("../src/config/prisma");
|
||||
const mailer = require("../src/config/mailer");
|
||||
const { passport } = require("../src/config/passport");
|
||||
|
||||
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
|
||||
|
||||
const EMAIL = "facebook-login-test@test.local";
|
||||
const EMAIL_EXISTANT = "facebook-link-test@test.local";
|
||||
|
||||
function profil(overrides = {}) {
|
||||
return {
|
||||
id: "sim-facebook-id-987654",
|
||||
displayName: "Léa Durand",
|
||||
name: { givenName: "Léa", familyName: "Durand" },
|
||||
emails: [{ value: EMAIL }],
|
||||
photos: [{ value: "https://example.test/fb.png" }],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// Rejoue le callback de la strategie Facebook sans sortir sur le reseau.
|
||||
function verifierProfilFacebook(profile) {
|
||||
const strategy = passport._strategy("facebook");
|
||||
return new Promise((resolve, reject) => {
|
||||
strategy._verify("access-token", "refresh-token", profile, (err, user, info) => {
|
||||
if (err) reject(err);
|
||||
else resolve({ user, info });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.user.deleteMany({ where: { email: { in: [EMAIL, EMAIL_EXISTANT] } } });
|
||||
}
|
||||
|
||||
beforeAll(cleanup);
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("Connexion Facebook", () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test("la route de depart redirige vers Facebook", async () => {
|
||||
const res = await request(app).get("/api/auth/facebook");
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
const url = new URL(res.headers.location);
|
||||
expect(url.hostname).toBe("www.facebook.com");
|
||||
expect(url.searchParams.get("client_id")).toBe("test-app-id");
|
||||
expect(url.searchParams.get("scope")).toBe("email");
|
||||
expect(url.searchParams.get("redirect_uri")).toMatch(/\/api\/auth\/facebook\/callback$/);
|
||||
});
|
||||
|
||||
test("un premier passage cree un compte FACEBOOK sans mot de passe", async () => {
|
||||
mailer.sendMail.mockResolvedValue(true);
|
||||
|
||||
const { user } = await verifierProfilFacebook(profil());
|
||||
|
||||
expect(user.provider).toBe("FACEBOOK");
|
||||
expect(user.providerId).toBe("sim-facebook-id-987654");
|
||||
expect(user.password).toBeNull();
|
||||
expect(user.isEmailVerified).toBe(true);
|
||||
expect(user.firstname).toBe("Léa");
|
||||
|
||||
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||
expect(mailer.sendMail.mock.calls[0][0].html).toContain("créé avec Facebook");
|
||||
});
|
||||
|
||||
test("un second passage reconnait le compte sans renvoyer d email", async () => {
|
||||
const { user } = await verifierProfilFacebook(profil());
|
||||
|
||||
expect(user.email).toBe(EMAIL);
|
||||
expect(mailer.sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("un compte existant avec le meme email est rattache a Facebook", async () => {
|
||||
await prisma.user.create({
|
||||
data: { firstname: "Paul", lastname: "Martin", email: EMAIL_EXISTANT, password: "hash", isEmailVerified: false },
|
||||
});
|
||||
|
||||
const { user } = await verifierProfilFacebook(
|
||||
profil({ id: "sim-facebook-id-link", emails: [{ value: EMAIL_EXISTANT }] })
|
||||
);
|
||||
|
||||
expect(user.provider).toBe("FACEBOOK");
|
||||
expect(user.isEmailVerified).toBe(true);
|
||||
// Le mot de passe existant est conserve.
|
||||
expect(user.password).toBe("hash");
|
||||
});
|
||||
|
||||
test("un compte Facebook sans email est refuse", async () => {
|
||||
const { user, info } = await verifierProfilFacebook(profil({ id: "sim-sans-email", emails: undefined }));
|
||||
|
||||
expect(user).toBe(false);
|
||||
expect(info.message).toMatch(/Facebook/);
|
||||
});
|
||||
|
||||
test("la connexion par mot de passe oriente vers le bouton Facebook", async () => {
|
||||
const res = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "PeuImporte123" });
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(res.body.message).toMatch(/connexion Facebook/);
|
||||
});
|
||||
});
|
||||
167
backend/tests/forgotPassword.test.js
Normal file
@@ -0,0 +1,167 @@
|
||||
const request = require("supertest");
|
||||
const bcrypt = require("bcrypt");
|
||||
const app = require("../src/app");
|
||||
const prisma = require("../src/config/prisma");
|
||||
const mailer = require("../src/config/mailer");
|
||||
|
||||
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) }));
|
||||
|
||||
const EMAIL = "forgot-password-test@test.local";
|
||||
const NON_VERIFIE = "forgot-password-unverified@test.local";
|
||||
const INSCRIT = "forgot-password-register@test.local";
|
||||
|
||||
// Jeton extrait du lien reçu par email.
|
||||
function tokenFromMail() {
|
||||
const html = mailer.sendMail.mock.calls.at(-1)[0].html;
|
||||
return html.match(/\/definir-mot-de-passe\/([a-f0-9]+)"/)[1];
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.user.deleteMany({ where: { email: { in: [EMAIL, NON_VERIFIE, INSCRIT] } } });
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
const h = await bcrypt.hash("AncienMdp1!", 10);
|
||||
await prisma.user.create({ data: { firstname: "Léa", lastname: "Durand", email: EMAIL, password: h, isEmailVerified: true } });
|
||||
await prisma.user.create({ data: { firstname: "Tom", lastname: "Petit", email: NON_VERIFIE, password: h, isEmailVerified: false } });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("Mot de passe oublie", () => {
|
||||
beforeEach(() => mailer.sendMail.mockClear());
|
||||
|
||||
test("un email inconnu recoit la meme reponse, sans envoi", async () => {
|
||||
const res = await request(app).post("/api/auth/forgot-password").send({ email: "inconnu@test.local" });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.message).toMatch(/Si un compte existe/);
|
||||
expect(mailer.sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("un email connu recoit un lien vers la page du front", async () => {
|
||||
const res = await request(app).post("/api/auth/forgot-password").send({ email: EMAIL });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||
const mail = mailer.sendMail.mock.calls[0][0];
|
||||
expect(mail.to).toBe(EMAIL);
|
||||
const frontendUrl = (process.env.FRONTEND_URL || "http://localhost:5173").split(",")[0].trim();
|
||||
expect(mail.html).toContain(`${frontendUrl}/definir-mot-de-passe/`);
|
||||
expect(tokenFromMail()).toHaveLength(64);
|
||||
});
|
||||
|
||||
test("une nouvelle demande invalide le lien precedent", async () => {
|
||||
await request(app).post("/api/auth/forgot-password").send({ email: EMAIL });
|
||||
const ancien = tokenFromMail();
|
||||
await request(app).post("/api/auth/forgot-password").send({ email: EMAIL });
|
||||
const nouveau = tokenFromMail();
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/auth/reset-password/${ancien}`)
|
||||
.send({ password: "NouveauMdp1!", confirmPassword: "NouveauMdp1!" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(nouveau).not.toBe(ancien);
|
||||
});
|
||||
|
||||
test("le lien permet de changer le mot de passe une seule fois", async () => {
|
||||
await request(app).post("/api/auth/forgot-password").send({ email: EMAIL });
|
||||
const token = tokenFromMail();
|
||||
|
||||
const ok = await request(app)
|
||||
.post(`/api/auth/reset-password/${token}`)
|
||||
.send({ password: "NouveauMdp1!", confirmPassword: "NouveauMdp1!" });
|
||||
expect(ok.status).toBe(200);
|
||||
|
||||
expect((await request(app).post("/api/auth/login").send({ email: EMAIL, password: "AncienMdp1!" })).status).toBe(401);
|
||||
expect((await request(app).post("/api/auth/login").send({ email: EMAIL, password: "NouveauMdp1!" })).status).toBe(200);
|
||||
|
||||
const reutilise = await request(app)
|
||||
.post(`/api/auth/reset-password/${token}`)
|
||||
.send({ password: "EncoreAutre1!", confirmPassword: "EncoreAutre1!" });
|
||||
expect(reutilise.status).toBe(400);
|
||||
expect(reutilise.body.message).toMatch(/invalide ou expiré/);
|
||||
});
|
||||
|
||||
test("un lien expire est refuse", async () => {
|
||||
await request(app).post("/api/auth/forgot-password").send({ email: EMAIL });
|
||||
const token = tokenFromMail();
|
||||
await prisma.user.update({ where: { email: EMAIL }, data: { resetPasswordExpires: new Date(Date.now() - 1000) } });
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/auth/reset-password/${token}`)
|
||||
.send({ password: "EncoreAutre1!", confirmPassword: "EncoreAutre1!" });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.message).toMatch(/invalide ou expiré/);
|
||||
});
|
||||
|
||||
test("l email est reconnu quelle que soit la casse et malgre des espaces", async () => {
|
||||
const res = await request(app).post("/api/auth/forgot-password").send({ email: ` ${EMAIL.toUpperCase()} ` });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||
expect(mailer.sendMail.mock.calls[0][0].to).toBe(EMAIL);
|
||||
});
|
||||
|
||||
test("la reinitialisation valide un compte jamais verifie, qui peut ensuite se connecter", async () => {
|
||||
await request(app).post("/api/auth/forgot-password").send({ email: NON_VERIFIE });
|
||||
await request(app)
|
||||
.post(`/api/auth/reset-password/${tokenFromMail()}`)
|
||||
.send({ password: "NouveauMdp1!", confirmPassword: "NouveauMdp1!" })
|
||||
.expect(200);
|
||||
|
||||
const login = await request(app).post("/api/auth/login").send({ email: NON_VERIFIE, password: "NouveauMdp1!" });
|
||||
expect(login.status).toBe(200);
|
||||
const enBase = await prisma.user.findUnique({ where: { email: NON_VERIFIE } });
|
||||
expect(enBase.isEmailVerified).toBe(true);
|
||||
expect(enBase.emailVerifyToken).toBeNull();
|
||||
});
|
||||
|
||||
test("le profil connecte recoit le lien sur l adresse du compte, sans captcha", async () => {
|
||||
await request(app).post("/api/auth/password-setup-link").expect(401);
|
||||
|
||||
const login = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "NouveauMdp1!" }).expect(200);
|
||||
const res = await request(app)
|
||||
.post("/api/auth/password-setup-link")
|
||||
.set("Authorization", `Bearer ${login.body.token}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(mailer.sendMail.mock.calls[0][0].to).toBe(EMAIL);
|
||||
expect(tokenFromMail()).toHaveLength(64);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Emails normalises a l inscription et a la connexion", () => {
|
||||
test("un email saisi avec majuscules est stocke en minuscules et reconnu a la connexion", async () => {
|
||||
const res = await request(app).post("/api/auth/register").send({
|
||||
firstname: "Nora",
|
||||
lastname: "Blanc",
|
||||
email: ` ${INSCRIT.replace("forgot", "FORGOT")} `,
|
||||
password: "MotDePasse1!",
|
||||
confirmPassword: "MotDePasse1!",
|
||||
birthdate: "1990-05-14",
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const enBase = await prisma.user.findUnique({ where: { email: INSCRIT } });
|
||||
expect(enBase).not.toBeNull();
|
||||
|
||||
// Même adresse avec une autre casse : doublon refusé.
|
||||
const doublon = await request(app).post("/api/auth/register").send({
|
||||
firstname: "Nora",
|
||||
lastname: "Blanc",
|
||||
email: INSCRIT.toUpperCase(),
|
||||
password: "MotDePasse1!",
|
||||
confirmPassword: "MotDePasse1!",
|
||||
});
|
||||
expect(doublon.status).toBe(409);
|
||||
|
||||
await prisma.user.update({ where: { email: INSCRIT }, data: { isEmailVerified: true } });
|
||||
const login = await request(app).post("/api/auth/login").send({ email: INSCRIT.toUpperCase(), password: "MotDePasse1!" });
|
||||
expect(login.status).toBe(200);
|
||||
});
|
||||
});
|
||||
113
backend/tests/gainNotification.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
187
backend/tests/gameFlow.test.js
Normal file
@@ -0,0 +1,187 @@
|
||||
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) } });
|
||||
|
||||
|
||||
// Un tirage reel peut exister en base : la table GrandPrizeWinner est unique
|
||||
// pour tout le jeu. On la met de cote le temps du test, puis on la restaure.
|
||||
async function mettreDeCoteLeGagnant() {
|
||||
const existant = await prisma.grandPrizeWinner.findFirst();
|
||||
if (existant) await prisma.grandPrizeWinner.delete({ where: { id: existant.id } });
|
||||
return existant;
|
||||
}
|
||||
|
||||
async function restaurerLeGagnant(sauvegarde) {
|
||||
if (!sauvegarde) return;
|
||||
await prisma.grandPrizeWinner.deleteMany({});
|
||||
await prisma.grandPrizeWinner.create({
|
||||
data: {
|
||||
id: sauvegarde.id,
|
||||
userId: sauvegarde.userId,
|
||||
prize: sauvegarde.prize,
|
||||
drawnAt: sauvegarde.drawnAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant();
|
||||
await cleanup();
|
||||
ctx.settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||
ctx.orig = ctx.settings.startDate;
|
||||
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 restaurerLeGagnant(ctx.gagnantSauvegarde);
|
||||
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);
|
||||
});
|
||||
});
|
||||
175
backend/tests/gamePhases.test.js
Normal file
@@ -0,0 +1,175 @@
|
||||
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" });
|
||||
|
||||
|
||||
// La table GrandPrizeWinner est unique pour tout le jeu : on met de cote un
|
||||
// eventuel tirage reel le temps du test, puis on le restaure.
|
||||
async function mettreDeCoteLeGagnant() {
|
||||
const existant = await prisma.grandPrizeWinner.findFirst();
|
||||
if (existant) await prisma.grandPrizeWinner.delete({ where: { id: existant.id } });
|
||||
return existant;
|
||||
}
|
||||
|
||||
async function restaurerLeGagnant(sauvegarde) {
|
||||
if (!sauvegarde) return;
|
||||
await prisma.grandPrizeWinner.deleteMany({});
|
||||
await prisma.grandPrizeWinner.create({
|
||||
data: { id: sauvegarde.id, userId: sauvegarde.userId, prize: sauvegarde.prize, drawnAt: sauvegarde.drawnAt },
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant();
|
||||
await cleanup();
|
||||
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||
ctx.startDateOriginale = row ? row.startDate : null;
|
||||
|
||||
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 () => {
|
||||
await restaurerLeGagnant(ctx.gagnantSauvegarde);
|
||||
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||
if (ctx.startDateOriginale) {
|
||||
if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: ctx.startDateOriginale } });
|
||||
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");
|
||||
|
||||
// Route publique de la page d'accueil : sans configuration, pas de date,
|
||||
// le front affiche alors les dates par défaut.
|
||||
const publique = await request(app).get("/api/game/window").expect(200);
|
||||
expect(publique.body).toMatchObject({ configured: false, startDate: null });
|
||||
|
||||
const essai = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
|
||||
log("un client tente de jouer ->", essai.status, essai.body.message);
|
||||
expect(essai.status).toBe(410);
|
||||
|
||||
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");
|
||||
|
||||
// La page d'accueil reçoit aussitôt la nouvelle date, sans authentification.
|
||||
const publique = await request(app).get("/api/game/window").expect(200);
|
||||
expect(publique.body.configured).toBe(true);
|
||||
expect(publique.body.startDate).toBe(futur);
|
||||
expect(new Date(publique.body.participationDeadline).getTime()).toBe(new Date(futur).getTime() + 30 * DAY);
|
||||
|
||||
const anticipe = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
|
||||
log("client joue avant ouverture ->", anticipe.status);
|
||||
log("message :", JSON.stringify(anticipe.body.message));
|
||||
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");
|
||||
});
|
||||
});
|
||||
159
backend/tests/googlePassword.test.js
Normal file
@@ -0,0 +1,159 @@
|
||||
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("un mot de passe sans caractere special est refuse", async () => {
|
||||
const res = await request(app)
|
||||
.post("/api/auth/reset-password/" + ctxToken)
|
||||
.send({ password: "MonMotDePasse1", confirmPassword: "MonMotDePasse1" });
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.message).toMatch(/caractère spécial/);
|
||||
});
|
||||
|
||||
test("le mot de passe defini permet ensuite de se connecter et de le changer", async () => {
|
||||
const ok = await request(app)
|
||||
.post("/api/auth/reset-password/" + ctxToken)
|
||||
.send({ password: "MonMotDePasse1!", confirmPassword: "MonMotDePasse1!" })
|
||||
.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);
|
||||
|
||||
// Même règle qu'à l'inscription pour le changement depuis le profil.
|
||||
const sansSpecial = await request(app)
|
||||
.patch("/api/auth/change-password")
|
||||
.set("Authorization", "Bearer " + login.body.token)
|
||||
.send({ currentPassword: "EncoreUnAutre2!", newPassword: "SansCaractere3" });
|
||||
expect(sansSpecial.status).toBe(400);
|
||||
expect(sansSpecial.body.message).toMatch(/caractère spécial/);
|
||||
});
|
||||
|
||||
test("une seconde connexion Google ne renvoie pas de nouveau lien", async () => {
|
||||
mailer.sendMail.mockResolvedValue(true);
|
||||
|
||||
const user = await verifierProfilGoogle();
|
||||
|
||||
expect(user.email).toBe(EMAIL);
|
||||
expect(mailer.sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
let ctxToken;
|
||||
64
backend/tests/oauthCallback.test.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// Ce fichier ne charge pas app.js, donc pas le .env : le JWT a besoin d'un secret.
|
||||
process.env.JWT_SECRET = process.env.JWT_SECRET || "test-secret";
|
||||
|
||||
const getFrontendUrl = require("../src/utils/frontendUrl");
|
||||
const { oauthCallback } = require("../src/controllers/auth.controller");
|
||||
|
||||
describe("getFrontendUrl", () => {
|
||||
const original = process.env.FRONTEND_URL;
|
||||
afterEach(() => {
|
||||
process.env.FRONTEND_URL = original;
|
||||
});
|
||||
|
||||
test("ne garde que la premiere origine d une liste (CORS prod)", () => {
|
||||
process.env.FRONTEND_URL = "https://site.fr, https://www.site.fr";
|
||||
expect(getFrontendUrl()).toBe("https://site.fr");
|
||||
});
|
||||
|
||||
test("retire le slash final", () => {
|
||||
process.env.FRONTEND_URL = "https://site.fr/";
|
||||
expect(getFrontendUrl()).toBe("https://site.fr");
|
||||
});
|
||||
|
||||
test("retombe sur localhost sans configuration", () => {
|
||||
delete process.env.FRONTEND_URL;
|
||||
expect(getFrontendUrl()).toBe("http://localhost:5173");
|
||||
});
|
||||
});
|
||||
|
||||
describe("oauthCallback", () => {
|
||||
const original = process.env.FRONTEND_URL;
|
||||
afterEach(() => {
|
||||
process.env.FRONTEND_URL = original;
|
||||
});
|
||||
|
||||
test("redirige vers une URL valide avec un profil lisible par le front", () => {
|
||||
process.env.FRONTEND_URL = "https://site.fr,https://www.site.fr";
|
||||
const res = { redirect: jest.fn() };
|
||||
// Accents (UTF-8) et « ~ » : ce profil produit un base64 contenant « + »,
|
||||
// qui deviendrait un espace sans encodage.
|
||||
const user = { id: 7, firstname: "Chloé", lastname: "Lefèvre", email: "ab~test@test.local", role: "CLIENT" };
|
||||
expect(Buffer.from(JSON.stringify(user)).toString("base64")).toContain("+");
|
||||
|
||||
oauthCallback({ user }, res);
|
||||
|
||||
const url = new URL(res.redirect.mock.calls[0][0]);
|
||||
expect(`${url.origin}${url.pathname}`).toBe("https://site.fr/auth/callback");
|
||||
expect(url.searchParams.get("token")).toBeTruthy();
|
||||
|
||||
// Même décodage que frontend/src/pages/AuthCallback.jsx.
|
||||
const bytes = Uint8Array.from(atob(url.searchParams.get("user")), (c) => c.charCodeAt(0));
|
||||
expect(JSON.parse(new TextDecoder().decode(bytes))).toEqual(user);
|
||||
});
|
||||
|
||||
test("renvoie une erreur au front quand passport n a pas d utilisateur", () => {
|
||||
process.env.FRONTEND_URL = "https://site.fr,https://www.site.fr";
|
||||
const res = { redirect: jest.fn() };
|
||||
|
||||
oauthCallback({}, res);
|
||||
|
||||
const url = new URL(res.redirect.mock.calls[0][0]);
|
||||
expect(url.origin).toBe("https://site.fr");
|
||||
expect(url.searchParams.get("error")).toMatch(/refusée/);
|
||||
});
|
||||
});
|
||||
67
backend/tests/register.mail.test.js
Normal file
@@ -0,0 +1,67 @@
|
||||
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!",
|
||||
confirmPassword: "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");
|
||||
});
|
||||
});
|
||||
62
backend/tests/registerPassword.test.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const request = require("supertest");
|
||||
const app = require("../src/app");
|
||||
const prisma = require("../src/config/prisma");
|
||||
|
||||
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) }));
|
||||
|
||||
const EMAIL = "register-password-test@test.local";
|
||||
|
||||
const payload = (password, confirmPassword = password) => ({
|
||||
firstname: "Camille",
|
||||
lastname: "Martin",
|
||||
email: EMAIL,
|
||||
password,
|
||||
confirmPassword,
|
||||
birthdate: "1990-05-14",
|
||||
});
|
||||
|
||||
async function cleanup() {
|
||||
await prisma.user.deleteMany({ where: { email: EMAIL } });
|
||||
}
|
||||
|
||||
beforeEach(cleanup);
|
||||
afterAll(async () => {
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("Regles du mot de passe a l inscription", () => {
|
||||
test.each([
|
||||
["trop court", "Abc12!", /8 caractères/],
|
||||
["sans caractere special", "MotDePasse123", /caractère spécial/],
|
||||
["accents et espaces ne comptent pas comme speciaux", "Mot de passé 123", /caractère spécial/],
|
||||
])("refuse un mot de passe %s", async (_, password, message) => {
|
||||
const res = await request(app).post("/api/auth/register").send(payload(password));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.errors.password[0]).toMatch(message);
|
||||
expect(await prisma.user.findUnique({ where: { email: EMAIL } })).toBeNull();
|
||||
});
|
||||
|
||||
test("refuse une confirmation differente", async () => {
|
||||
const res = await request(app).post("/api/auth/register").send(payload("MotDePasse1!", "MotDePasse2!"));
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.errors.confirmPassword[0]).toMatch(/ne correspondent pas/);
|
||||
});
|
||||
|
||||
test("refuse une inscription sans confirmation", async () => {
|
||||
const { confirmPassword, ...sansConfirmation } = payload("MotDePasse1!");
|
||||
const res = await request(app).post("/api/auth/register").send(sansConfirmation);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.errors.confirmPassword).toBeDefined();
|
||||
});
|
||||
|
||||
test("accepte 8 caracteres avec un caractere special", async () => {
|
||||
const res = await request(app).post("/api/auth/register").send(payload("abcdefg#"));
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(await prisma.user.findUnique({ where: { email: EMAIL } })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
4
backend/tests/setupEnv.js
Normal file
@@ -0,0 +1,4 @@
|
||||
// Exécuté avant chaque fichier de test, donc avant le dotenv de src/app.js :
|
||||
// dotenv n'écrase pas une variable déjà définie, le captcha reste désactivé
|
||||
// pour les tests d'API (captcha.middleware.test.js le teste à part).
|
||||
process.env.TURNSTILE_SECRET_KEY = "";
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
POSTGRES_DB: the_tip_top_db
|
||||
|
||||
volumes:
|
||||
- tiptop_postgres_dev_data:/var/lib/postgresql/data
|
||||
- tiptop_dev_postgres_data:/var/lib/postgresql/data
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U tiptop -d the_tip_top_db"]
|
||||
@@ -24,20 +24,30 @@ services:
|
||||
image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG}
|
||||
restart: unless-stopped
|
||||
|
||||
env_file:
|
||||
- /opt/furious-ducks/secrets/tiptop-mail.env
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
environment:
|
||||
PORT: 5000
|
||||
NODE_ENV: development
|
||||
DATABASE_URL: postgresql://tiptop:tiptop_dev_password@postgres:5432/the_tip_top_db
|
||||
JWT_SECRET: dev_secret_change_me
|
||||
APP_URL: http://192.168.1.23:5001
|
||||
MAIL_HOST: smtp.gmail.com
|
||||
MAIL_PORT: 587
|
||||
MAIL_USER: ""
|
||||
MAIL_PASS: ""
|
||||
MAIL_FROM: "The Tip Top <no-reply@tiptop.local>"
|
||||
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
|
||||
|
||||
# Injectés par le pipeline, jamais commités. Vides, les routes de
|
||||
# connexion externe répondent 503 sans bloquer le démarrage.
|
||||
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}"
|
||||
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
|
||||
FACEBOOK_APP_ID: "${FACEBOOK_APP_ID}"
|
||||
FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET}"
|
||||
# Vide, la vérification anti-robot Turnstile est désactivée.
|
||||
TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY}"
|
||||
|
||||
command: >
|
||||
sh -c "
|
||||
@@ -46,11 +56,18 @@ services:
|
||||
npm start
|
||||
"
|
||||
|
||||
ports:
|
||||
- "5001:5000"
|
||||
|
||||
networks:
|
||||
- tiptop-dev
|
||||
- proxy
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
- "traefik.http.routers.tiptop-dev-api.rule=Host(`api-dev.dsp5-archi-o24a-4-5-g4.fr`)"
|
||||
- "traefik.http.routers.tiptop-dev-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.tiptop-dev-api.tls=true"
|
||||
- "traefik.http.routers.tiptop-dev-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.tiptop-dev-api.loadbalancer.server.port=5000"
|
||||
|
||||
frontend:
|
||||
image: ${REGISTRY}/the-tip-top/frontend:${IMAGE_TAG}
|
||||
@@ -59,17 +76,26 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
ports:
|
||||
- "5173:80"
|
||||
|
||||
networks:
|
||||
- tiptop-dev
|
||||
- proxy
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
- "traefik.http.routers.tiptop-dev.rule=Host(`dev.dsp5-archi-o24a-4-5-g4.fr`)"
|
||||
- "traefik.http.routers.tiptop-dev.entrypoints=websecure"
|
||||
- "traefik.http.routers.tiptop-dev.tls=true"
|
||||
- "traefik.http.routers.tiptop-dev.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.tiptop-dev.loadbalancer.server.port=80"
|
||||
|
||||
volumes:
|
||||
tiptop_postgres_dev_data:
|
||||
external: true
|
||||
name: the-tip-top_tiptop_postgres_dev_data
|
||||
tiptop_dev_postgres_data:
|
||||
name: tiptop_dev_postgres_data
|
||||
|
||||
networks:
|
||||
tiptop-dev:
|
||||
name: tiptop-dev
|
||||
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
@@ -12,11 +12,7 @@ services:
|
||||
- tiptop_preprod_postgres_data:/var/lib/postgresql/data
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U tiptop_preprod -d the_tip_top_preprod"
|
||||
]
|
||||
test: ["CMD-SHELL", "pg_isready -U tiptop_preprod -d the_tip_top_preprod"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -28,6 +24,9 @@ services:
|
||||
image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG}
|
||||
restart: unless-stopped
|
||||
|
||||
env_file:
|
||||
- /opt/furious-ducks/secrets/tiptop-mail.env
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -37,12 +36,18 @@ services:
|
||||
NODE_ENV: preproduction
|
||||
DATABASE_URL: postgresql://tiptop_preprod:tiptop_preprod_password@postgres:5432/the_tip_top_preprod
|
||||
JWT_SECRET: preprod_secret_change_me
|
||||
APP_URL: http://192.168.1.23:5002
|
||||
MAIL_HOST: smtp.gmail.com
|
||||
MAIL_PORT: 587
|
||||
MAIL_USER: ""
|
||||
MAIL_PASS: ""
|
||||
MAIL_FROM: "The Tip Top Preprod <no-reply@tiptop.local>"
|
||||
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
|
||||
|
||||
# Injectés par le pipeline, jamais commités. Vides, les routes de
|
||||
# connexion externe répondent 503 sans bloquer le démarrage.
|
||||
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}"
|
||||
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
|
||||
FACEBOOK_APP_ID: "${FACEBOOK_APP_ID}"
|
||||
FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET}"
|
||||
# Vide, la vérification anti-robot Turnstile est désactivée.
|
||||
TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY}"
|
||||
|
||||
command: >
|
||||
sh -c "
|
||||
@@ -51,11 +56,18 @@ services:
|
||||
npm start
|
||||
"
|
||||
|
||||
ports:
|
||||
- "5002:5000"
|
||||
|
||||
networks:
|
||||
- tiptop-preprod
|
||||
- proxy
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
- "traefik.http.routers.tiptop-preprod-api.rule=Host(`api-preprod.dsp5-archi-o24a-4-5-g4.fr`)"
|
||||
- "traefik.http.routers.tiptop-preprod-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.tiptop-preprod-api.tls=true"
|
||||
- "traefik.http.routers.tiptop-preprod-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.tiptop-preprod-api.loadbalancer.server.port=5000"
|
||||
|
||||
frontend:
|
||||
image: ${REGISTRY}/the-tip-top/frontend:${IMAGE_TAG}
|
||||
@@ -64,11 +76,18 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
ports:
|
||||
- "5174:80"
|
||||
|
||||
networks:
|
||||
- tiptop-preprod
|
||||
- proxy
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
- "traefik.http.routers.tiptop-preprod.rule=Host(`preprod.dsp5-archi-o24a-4-5-g4.fr`)"
|
||||
- "traefik.http.routers.tiptop-preprod.entrypoints=websecure"
|
||||
- "traefik.http.routers.tiptop-preprod.tls=true"
|
||||
- "traefik.http.routers.tiptop-preprod.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.tiptop-preprod.loadbalancer.server.port=80"
|
||||
|
||||
volumes:
|
||||
tiptop_preprod_postgres_data:
|
||||
@@ -77,3 +96,6 @@ volumes:
|
||||
networks:
|
||||
tiptop-preprod:
|
||||
name: tiptop-preprod
|
||||
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
@@ -12,11 +12,7 @@ services:
|
||||
- tiptop_prod_postgres_data:/var/lib/postgresql/data
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U tiptop_prod -d the_tip_top_prod"
|
||||
]
|
||||
test: ["CMD-SHELL", "pg_isready -U tiptop_prod -d the_tip_top_prod"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
@@ -28,6 +24,9 @@ services:
|
||||
image: ${REGISTRY}/the-tip-top/backend:${IMAGE_TAG}
|
||||
restart: unless-stopped
|
||||
|
||||
env_file:
|
||||
- /opt/furious-ducks/secrets/tiptop-mail.env
|
||||
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -37,13 +36,18 @@ services:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgresql://tiptop_prod:tiptop_prod_password@postgres:5432/the_tip_top_prod
|
||||
JWT_SECRET: prod_secret_change_me
|
||||
APP_URL: http://192.168.1.23:5003
|
||||
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,https://www.dsp5-archi-o24a-4-5-g4.fr
|
||||
|
||||
MAIL_HOST: smtp.gmail.com
|
||||
MAIL_PORT: 587
|
||||
MAIL_USER: ""
|
||||
MAIL_PASS: ""
|
||||
MAIL_FROM: "The Tip Top <no-reply@tiptop.local>"
|
||||
# Injectés par le pipeline, jamais commités. Vides, les routes de
|
||||
# connexion externe répondent 503 sans bloquer le démarrage.
|
||||
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}"
|
||||
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
|
||||
FACEBOOK_APP_ID: "${FACEBOOK_APP_ID}"
|
||||
FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET}"
|
||||
# Vide, la vérification anti-robot Turnstile est désactivée.
|
||||
TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY}"
|
||||
|
||||
command: >
|
||||
sh -c "
|
||||
@@ -52,11 +56,18 @@ services:
|
||||
npm start
|
||||
"
|
||||
|
||||
ports:
|
||||
- "5003:5000"
|
||||
|
||||
networks:
|
||||
- tiptop-prod
|
||||
- proxy
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
- "traefik.http.routers.tiptop-prod-api.rule=Host(`api.dsp5-archi-o24a-4-5-g4.fr`)"
|
||||
- "traefik.http.routers.tiptop-prod-api.entrypoints=websecure"
|
||||
- "traefik.http.routers.tiptop-prod-api.tls=true"
|
||||
- "traefik.http.routers.tiptop-prod-api.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.tiptop-prod-api.loadbalancer.server.port=5000"
|
||||
|
||||
frontend:
|
||||
image: ${REGISTRY}/the-tip-top/frontend:${IMAGE_TAG}
|
||||
@@ -65,11 +76,18 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
ports:
|
||||
- "5175:80"
|
||||
|
||||
networks:
|
||||
- tiptop-prod
|
||||
- proxy
|
||||
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=proxy"
|
||||
- "traefik.http.routers.tiptop-prod.rule=Host(`dsp5-archi-o24a-4-5-g4.fr`) || Host(`www.dsp5-archi-o24a-4-5-g4.fr`)"
|
||||
- "traefik.http.routers.tiptop-prod.entrypoints=websecure"
|
||||
- "traefik.http.routers.tiptop-prod.tls=true"
|
||||
- "traefik.http.routers.tiptop-prod.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.tiptop-prod.loadbalancer.server.port=80"
|
||||
|
||||
volumes:
|
||||
tiptop_prod_postgres_data:
|
||||
@@ -78,3 +96,6 @@ volumes:
|
||||
networks:
|
||||
tiptop-prod:
|
||||
name: tiptop-prod
|
||||
|
||||
proxy:
|
||||
external: true
|
||||
|
||||
5
frontend/.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
@@ -22,3 +23,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Tests / coverage
|
||||
coverage/
|
||||
test-results/
|
||||
|
||||
BIN
frontend/Design/Code gagnang.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
frontend/Design/Inscription reussit.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/Design/Saisir_code.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/Design/historique des gain .png
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
frontend/Design/profil .png
Normal file
|
After Width: | Height: | Size: 13 KiB |
@@ -1,14 +1,21 @@
|
||||
FROM node:22-alpine AS build
|
||||
FROM node:22-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
RUN npx playwright install --with-deps chromium
|
||||
|
||||
COPY . .
|
||||
|
||||
ARG VITE_API_URL
|
||||
ARG VITE_GA_ID
|
||||
|
||||
ENV VITE_API_URL=${VITE_API_URL}
|
||||
ENV VITE_GA_ID=${VITE_GA_ID}
|
||||
|
||||
ARG VITE_TURNSTILE_SITE_KEY
|
||||
ENV VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -8,6 +8,7 @@ export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{js,jsx}'],
|
||||
ignores: ['scripts/**'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
@@ -18,4 +19,13 @@ export default defineConfig([
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
},
|
||||
{
|
||||
// Scripts Node exécutés à la compilation (ex. prerender.js), jamais
|
||||
// dans le navigateur : globals Node, pas de règles React.
|
||||
files: ['scripts/**/*.js'],
|
||||
extends: [js.configs.recommended],
|
||||
languageOptions: {
|
||||
globals: globals.node,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
@@ -1,10 +1,47 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ttt-front</title>
|
||||
<meta name="description" content="Participez au jeu-concours Thé Tip Top : saisissez le code de votre ticket et découvrez immédiatement votre lot." />
|
||||
<link rel="canonical" href="https://www.dsp5-archi-o24a-4-5-g4.fr/accueil" />
|
||||
<title>Jeu concours - Thé Tip Top</title>
|
||||
|
||||
<!-- Open Graph : valeurs par défaut, mises à jour par page via le hook usePageMeta -->
|
||||
<meta property="og:site_name" content="Thé Tip Top" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="fr_FR" />
|
||||
<meta property="og:title" content="Jeu concours - Thé Tip Top" />
|
||||
<meta property="og:description" content="Participez au jeu-concours Thé Tip Top : saisissez le code de votre ticket et découvrez immédiatement votre lot." />
|
||||
<meta property="og:url" content="https://www.dsp5-archi-o24a-4-5-g4.fr/accueil" />
|
||||
<meta property="og:image" content="https://www.dsp5-archi-o24a-4-5-g4.fr/og-image.webp" />
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta name="twitter:title" content="Jeu concours - Thé Tip Top" />
|
||||
<meta name="twitter:description" content="Participez au jeu-concours Thé Tip Top : saisissez le code de votre ticket et découvrez immédiatement votre lot." />
|
||||
<meta name="twitter:image" content="https://www.dsp5-archi-o24a-4-5-g4.fr/og-image.webp" />
|
||||
|
||||
<!-- Schema.org : identité du site uniquement, aucune adresse (non communiquée) -->
|
||||
<script type="application/ld+json">
|
||||
{
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Organization",
|
||||
"name": "Thé Tip Top",
|
||||
"url": "https://www.dsp5-archi-o24a-4-5-g4.fr/",
|
||||
"email": "thetiptop.gp4@gmail.com"
|
||||
},
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"name": "Thé Tip Top",
|
||||
"url": "https://www.dsp5-archi-o24a-4-5-g4.fr/"
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -6,6 +6,12 @@ server {
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
# $uri/index.html (et non $uri/) : sert directement le fichier des
|
||||
# pages pré-rendues (ex. /accueil -> dist/accueil/index.html) sans
|
||||
# passer par la redirection 301 vers l'URL à slash final que nginx
|
||||
# déclenche pour une résolution de type répertoire ($uri/). Les
|
||||
# canonical/sitemap pointent vers les URLs sans slash : elles
|
||||
# doivent répondre 200 directement, pas via un redirect.
|
||||
try_files $uri $uri/index.html /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
1425
frontend/package-lock.json
generated
@@ -5,11 +5,16 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build": "vite build && node scripts/prerender.js",
|
||||
"prerender": "node scripts/prerender.js",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@marsidev/react-turnstile": "^1.6.1",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-router-dom": "^7.17.0"
|
||||
@@ -17,14 +22,21 @@
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.11",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"playwright": "^1.62.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"vite": "^8.0.12"
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 9.3 KiB After Width: | Height: | Size: 9.6 KiB |
BIN
frontend/public/og-image.webp
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
frontend/public/reglement-jeu-concours-100-gagnant.pdf
Normal file
26
frontend/public/robots.txt
Normal file
@@ -0,0 +1,26 @@
|
||||
User-agent: *
|
||||
|
||||
# Pages publiques autorisées
|
||||
Allow: /
|
||||
Allow: /accueil
|
||||
Allow: /notre-concept
|
||||
Allow: /regles-jeu
|
||||
Allow: /contact
|
||||
Allow: /mentions-legales
|
||||
Allow: /confidentialite
|
||||
Allow: /accessibilite
|
||||
Allow: /cgu
|
||||
Allow: /sitemap
|
||||
Allow: /inscription
|
||||
Allow: /connexion
|
||||
|
||||
# Pages privées interdites aux robots
|
||||
Disallow: /admin
|
||||
Disallow: /employe
|
||||
Disallow: /profile
|
||||
Disallow: /mes-gains
|
||||
Disallow: /saisir-code-unique
|
||||
Disallow: /perdu
|
||||
|
||||
# Sitemap
|
||||
Sitemap: https://www.dsp5-archi-o24a-4-5-g4.fr/sitemap.xml
|
||||
14
frontend/public/sitemap.xml
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/accueil</loc></url>
|
||||
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/notre-concept</loc></url>
|
||||
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/regles-jeu</loc></url>
|
||||
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/contact</loc></url>
|
||||
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/mentions-legales</loc></url>
|
||||
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/cgu</loc></url>
|
||||
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/confidentialite</loc></url>
|
||||
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/accessibilite</loc></url>
|
||||
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/sitemap</loc></url>
|
||||
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/inscription</loc></url>
|
||||
<url><loc>https://www.dsp5-archi-o24a-4-5-g4.fr/connexion</loc></url>
|
||||
</urlset>
|
||||
57
frontend/scripts/prerender.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { preview } from 'vite'
|
||||
import { chromium } from 'playwright'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const root = join(__dirname, '..')
|
||||
const distDir = join(root, 'dist')
|
||||
|
||||
// Pages publiques, indexables (voir public/robots.txt) : ce sont les seules
|
||||
// qui ont besoin d'un HTML pré-rendu pour le SEO et les aperçus sociaux. Les
|
||||
// pages privées (profil, mes gains, admin...) restent en rendu 100% client,
|
||||
// servies par le index.html générique via le fallback SPA de nginx.
|
||||
const ROUTES = [
|
||||
'/accueil',
|
||||
'/notre-concept',
|
||||
'/regles-jeu',
|
||||
'/contact',
|
||||
'/mentions-legales',
|
||||
'/confidentialite',
|
||||
'/accessibilite',
|
||||
'/cgu',
|
||||
'/sitemap',
|
||||
'/inscription',
|
||||
'/connexion',
|
||||
]
|
||||
|
||||
async function main() {
|
||||
const server = await preview({ root, preview: { port: 4173, strictPort: false } })
|
||||
const base = server.resolvedUrls.local[0].replace(/\/$/, '')
|
||||
|
||||
const browser = await chromium.launch({
|
||||
executablePath: process.env.CHROMIUM_PATH || chromium.executablePath(),
|
||||
headless: true,
|
||||
})
|
||||
const page = await browser.newPage()
|
||||
|
||||
for (const route of ROUTES) {
|
||||
await page.goto(`${base}${route}`, { waitUntil: 'networkidle' })
|
||||
await page.waitForSelector('h1')
|
||||
|
||||
const html = await page.content()
|
||||
const outDir = join(distDir, route.replace(/^\//, ''))
|
||||
mkdirSync(outDir, { recursive: true })
|
||||
writeFileSync(join(outDir, 'index.html'), `<!doctype html>\n${html}`)
|
||||
console.log(`✓ pré-rendu ${route} -> dist${route}/index.html`)
|
||||
}
|
||||
|
||||
await browser.close()
|
||||
await server.close()
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Erreur de pré-rendu :', error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,23 +1,95 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider } from './context/AuthContext.jsx'
|
||||
import Layout from './components/Layout.jsx'
|
||||
import ProtectedRoute from './components/ProtectedRoute.jsx'
|
||||
import CookieBanner from './components/CookieBanner.jsx'
|
||||
import Connexion from './pages/Connexion'
|
||||
import Inscription from './pages/Inscription'
|
||||
import Participation from './pages/Participation'
|
||||
import AuthCallback from './pages/AuthCallback'
|
||||
import DefinirMotDePasse from './pages/DefinirMotDePasse'
|
||||
import MotDePasseOublie from './pages/MotDePasseOublie'
|
||||
import NouveauMotDePasse from './pages/NouveauMotDePasse'
|
||||
import Accueil from './pages/Accueil'
|
||||
import NotreConcept from './pages/NotreConcept'
|
||||
import ReglesJeu from './pages/ReglesJeu'
|
||||
import MesGains from './pages/MesGains'
|
||||
import Employe from './pages/Employe'
|
||||
import Admin from './pages/Admin'
|
||||
import Profile from './pages/Profile'
|
||||
import SaisirCodeUnique from './pages/SaisirCodeUnique.jsx'
|
||||
import Perdu from './pages/Perdu'
|
||||
import NotFound from './pages/NotFound'
|
||||
import Sitemap from './pages/Sitemap'
|
||||
import MentionsLegales from './pages/MentionsLegales'
|
||||
import Confidentialite from './pages/Confidentialite'
|
||||
import Accessibilite from './pages/Accessibilite'
|
||||
import Cgu from './pages/Cgu'
|
||||
import Contact from './pages/Contact.jsx'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/connexion" replace />} />
|
||||
<Route path="/" element={<Navigate to="/accueil" replace />} />
|
||||
<Route path="/connexion" element={<Connexion />} />
|
||||
<Route path="/inscription" element={<Inscription />} />
|
||||
<Route path="/participation" element={<Participation />} />
|
||||
<Route path="/mes-gains" element={<MesGains />} />
|
||||
<Route path="/employe" element={<Employe />} />
|
||||
<Route path="/admin" element={<Admin />} />
|
||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||
<Route path="/mot-de-passe-oublie" element={<MotDePasseOublie />} />
|
||||
<Route path="/definir-mot-de-passe/:token" element={<DefinirMotDePasse />} />
|
||||
<Route path="/nouveau-mot-de-passe" element={<NouveauMotDePasse />} />
|
||||
|
||||
{/* Pages publiques */}
|
||||
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />
|
||||
<Route path="/notre-concept" element={<Layout><NotreConcept /></Layout>} />
|
||||
<Route path="/regles-jeu" element={<Layout><ReglesJeu /></Layout>} />
|
||||
<Route path="/mentions-legales" element={<Layout><MentionsLegales /></Layout>} />
|
||||
<Route path="/confidentialite" element={<Layout><Confidentialite /></Layout>} />
|
||||
<Route path="/accessibilite" element={<Layout><Accessibilite /></Layout>} />
|
||||
<Route path="/cgu" element={<Layout><Cgu /></Layout>} />
|
||||
<Route path="/contact" element={<Layout><Contact /></Layout>} />
|
||||
<Route path="/sitemap" element={<Layout><Sitemap /></Layout>} />
|
||||
|
||||
{/* Pages CLIENT */}
|
||||
<Route path="/mes-gains" element={
|
||||
<ProtectedRoute roles={['CLIENT']}>
|
||||
<Layout><MesGains /></Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/saisir-code-unique" element={
|
||||
<ProtectedRoute roles={['CLIENT']}>
|
||||
<Layout><SaisirCodeUnique /></Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/profile" element={
|
||||
<ProtectedRoute roles={['CLIENT']}>
|
||||
<Layout><Profile /></Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
<Route path="/perdu" element={
|
||||
<ProtectedRoute roles={['CLIENT']}>
|
||||
<Layout><Perdu /></Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Page EMPLOYEE */}
|
||||
<Route path="/employe" element={
|
||||
<ProtectedRoute roles={['EMPLOYEE']}>
|
||||
<Layout><Employe /></Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
{/* Page ADMIN */}
|
||||
<Route path="/admin" element={
|
||||
<ProtectedRoute roles={['ADMIN']}>
|
||||
<Layout><Admin /></Layout>
|
||||
</ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<CookieBanner />
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
BIN
frontend/src/assets/img/banner-accueil.webp
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
frontend/src/assets/img/banner-the-tip-top.webp
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
frontend/src/assets/img/img-contact.webp
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
frontend/src/assets/img/logo.webp
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
125
frontend/src/components/Captcha.jsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { CAPTCHA_ENABLED, TURNSTILE_SITE_KEY } from '../config/captcha'
|
||||
|
||||
const TURNSTILE_SCRIPT_ID = 'cf-turnstile-script'
|
||||
const TURNSTILE_SCRIPT_URL =
|
||||
'https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit'
|
||||
|
||||
export default function Captcha({ onToken }) {
|
||||
const containerRef = useRef(null)
|
||||
const widgetIdRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!CAPTCHA_ENABLED || !TURNSTILE_SITE_KEY || !containerRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
let localWidgetId = null
|
||||
|
||||
const renderWidget = () => {
|
||||
if (
|
||||
cancelled ||
|
||||
!containerRef.current ||
|
||||
!window.turnstile
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Nettoyage d'un éventuel widget précédent.
|
||||
if (localWidgetId !== null) {
|
||||
try {
|
||||
window.turnstile.remove(localWidgetId)
|
||||
} catch {
|
||||
// Widget déjà supprimé.
|
||||
}
|
||||
|
||||
localWidgetId = null
|
||||
widgetIdRef.current = null
|
||||
}
|
||||
|
||||
containerRef.current.innerHTML = ''
|
||||
|
||||
localWidgetId = window.turnstile.render(
|
||||
containerRef.current,
|
||||
{
|
||||
sitekey: TURNSTILE_SITE_KEY,
|
||||
language: 'fr',
|
||||
size: 'flexible',
|
||||
appearance: 'always',
|
||||
|
||||
callback: (token) => {
|
||||
onToken(token)
|
||||
},
|
||||
|
||||
'expired-callback': () => {
|
||||
onToken('')
|
||||
},
|
||||
|
||||
'error-callback': () => {
|
||||
onToken('')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
widgetIdRef.current = localWidgetId
|
||||
}
|
||||
|
||||
const existingScript = document.getElementById(
|
||||
TURNSTILE_SCRIPT_ID
|
||||
)
|
||||
|
||||
if (window.turnstile) {
|
||||
renderWidget()
|
||||
} else if (existingScript) {
|
||||
existingScript.addEventListener('load', renderWidget, {
|
||||
once: true,
|
||||
})
|
||||
} else {
|
||||
const script = document.createElement('script')
|
||||
|
||||
script.id = TURNSTILE_SCRIPT_ID
|
||||
script.src = TURNSTILE_SCRIPT_URL
|
||||
script.async = true
|
||||
script.defer = true
|
||||
|
||||
script.addEventListener('load', renderWidget, {
|
||||
once: true,
|
||||
})
|
||||
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
|
||||
const widgetId =
|
||||
localWidgetId !== null
|
||||
? localWidgetId
|
||||
: widgetIdRef.current
|
||||
|
||||
if (widgetId !== null && window.turnstile) {
|
||||
try {
|
||||
window.turnstile.remove(widgetId)
|
||||
} catch {
|
||||
// Widget déjà supprimé.
|
||||
}
|
||||
}
|
||||
|
||||
localWidgetId = null
|
||||
widgetIdRef.current = null
|
||||
}
|
||||
}, [onToken])
|
||||
|
||||
if (!CAPTCHA_ENABLED || !TURNSTILE_SITE_KEY) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
id="turnstile-captcha"
|
||||
className="w-full min-h-[65px] flex justify-center"
|
||||
/>
|
||||
)
|
||||
}
|
||||
79
frontend/src/components/CookieBanner.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { initAnalytics, trackPageView } from '../services/analytics'
|
||||
|
||||
export default function CookieBanner() {
|
||||
const location = useLocation()
|
||||
|
||||
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)
|
||||
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">
|
||||
Gestion des cookies
|
||||
</p>
|
||||
|
||||
<p className="text-xs text-gray-500 leading-relaxed">
|
||||
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>
|
||||
)
|
||||
}
|
||||
269
frontend/src/components/Footer.jsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
function FooterLink({ to, children, className = '', pathname }) {
|
||||
return pathname === to ? (
|
||||
<span className={className}>{children}</span>
|
||||
) : (
|
||||
<Link to={to} className={className}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Footer() {
|
||||
const { pathname } = useLocation()
|
||||
const { user } = useAuth()
|
||||
const isLoggedIn = !!user
|
||||
|
||||
return (
|
||||
<footer className="bg-[#006B6B] text-white">
|
||||
|
||||
{/* Partie principale */}
|
||||
<div className="max-w-7xl mx-auto px-6 py-10 grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-8">
|
||||
|
||||
{/* Logo */}
|
||||
<div>
|
||||
<FooterLink
|
||||
to="/accueil"
|
||||
pathname={pathname}
|
||||
>
|
||||
<h3 className="text-xl font-bold mb-3">
|
||||
Thé Tip Top
|
||||
</h3>
|
||||
</FooterLink>
|
||||
|
||||
<p className="text-teal-100 text-sm leading-relaxed">
|
||||
Participez au jeu-concours Thé Tip Top et tentez de gagner
|
||||
des lots gourmands autour du thé.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-5">
|
||||
Navigation
|
||||
</h3>
|
||||
|
||||
<ul className="space-y-3 text-teal-100">
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/accueil"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Accueil
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/notre-concept"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Notre concept
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/regles-jeu"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Règles du jeu
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/saisir-code-unique"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Tester mon code
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
{!isLoggedIn && (
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/inscription"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Créer un compte
|
||||
</FooterLink>
|
||||
</li>
|
||||
)}
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Informations légales */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-5">
|
||||
Informations
|
||||
</h3>
|
||||
|
||||
<ul className="space-y-3 text-teal-100">
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/mentions-legales"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Mentions légales
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/confidentialite"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Politique de confidentialité
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/accessibilite"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Accessibilité
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/cgu"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
CGU
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/contact"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Contact
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<FooterLink
|
||||
to="/sitemap"
|
||||
pathname={pathname}
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Plan du site
|
||||
</FooterLink>
|
||||
</li>
|
||||
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Contact */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-5">
|
||||
Contact
|
||||
</h3>
|
||||
|
||||
<p className="text-teal-100 text-sm mb-3">
|
||||
Une question sur le jeu ?
|
||||
</p>
|
||||
|
||||
<a
|
||||
href="tel:+33184802034"
|
||||
className="block mt-2 font-medium hover:text-teal-300"
|
||||
>
|
||||
01 84 80 20 34
|
||||
</a>
|
||||
<a
|
||||
href="mailto:contact@thetiptop.fr"
|
||||
className="inline-block mt-3 font-medium hover:text-teal-300"
|
||||
>
|
||||
contact@thetiptop.fr
|
||||
</a>
|
||||
|
||||
<p className="text-teal-100 text-sm mt-2">
|
||||
18 rue Léon Frot
|
||||
<br />
|
||||
75011 Paris, France
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Barre basse */}
|
||||
<div className="border-t border-teal-800">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4 text-center">
|
||||
<p className="text-teal-100 text-xs leading-relaxed">
|
||||
Jeu avec obligation d'achat. Participation réservée aux personnes majeures résidant en
|
||||
France métropolitaine, sur présentation d'un ticket de caisse supérieur à 49 € émis
|
||||
dans la boutique Thé Tip Top de Nice. Le nombre de participations n'augmente pas les
|
||||
chances de remporter le gros lot.{' '}
|
||||
<a
|
||||
href="/reglement-jeu-concours-100-gagnant.pdf"
|
||||
className="underline hover:text-white"
|
||||
>
|
||||
Télécharger le règlement (PDF)
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-6 py-5 flex flex-col md:flex-row justify-between items-center gap-3">
|
||||
|
||||
<p className="text-teal-100 text-sm text-center md:text-left">
|
||||
© {new Date().getFullYear()} Thé Tip Top. Tous droits réservés.
|
||||
<br className="md:hidden" />
|
||||
<span className="md:before:content-['_—_']">
|
||||
Projet étudiant fictif — aucun achat ni réservation réel ne peut être effectué sur ce site.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="flex gap-5 text-sm text-teal-200">
|
||||
|
||||
<a
|
||||
href="https://www.facebook.com/profile.php?id=61590449280854"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Facebook
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://www.instagram.com/thetiptop.gp4?igsh=MXZ3YzZ0d3dkazBpeg%3D%3D&utm_source=qr"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
Instagram
|
||||
</a>
|
||||
|
||||
<a
|
||||
href="https://www.tiktok.com/@thetiptop4"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-white transition"
|
||||
>
|
||||
TikTok
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
153
frontend/src/components/Header.jsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import logo from '../assets/img/logo.webp'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export default function Header() {
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const { pathname } = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuth()
|
||||
const isLoggedIn = !!user
|
||||
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()
|
||||
setMenuOpen(false)
|
||||
navigate('/connexion')
|
||||
}
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/accueil', label: 'Accueil' },
|
||||
{ to: '/notre-concept', label: 'Notre concept' },
|
||||
{ to: '/regles-jeu', label: 'Règles du jeu' },
|
||||
{ to: '/contact', label: 'Contact' },
|
||||
]
|
||||
|
||||
const initials = user
|
||||
? `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase()
|
||||
: null
|
||||
|
||||
return (
|
||||
<header className="bg-white shadow-sm relative z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
|
||||
{pathname === '/accueil' ? (
|
||||
<span className="flex items-center flex-shrink-0">
|
||||
{logoContent}
|
||||
</span>
|
||||
) : (
|
||||
<Link to ={'/accueil'} className='flex items-center flex-shrink-0'>
|
||||
{logoContent}
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Nav desktop */}
|
||||
<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 whitespace-nowrap transition-colors ${
|
||||
pathname === link.to ? 'text-[#006B6B]' : 'text-gray-700 hover:text-[#006B6B]'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{isLoggedIn ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/profile" className="flex items-center gap-2 text-sm text-gray-700 hover:text-[#006B6B] font-medium transition-colors">
|
||||
<div className="w-8 h-8 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-xs font-bold">
|
||||
{initials}
|
||||
</div>
|
||||
<span className="whitespace-nowrap">{user.firstname}</span>
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
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] 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 whitespace-nowrap">
|
||||
Inscription
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Hamburger mobile */}
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="md:hidden flex flex-col justify-center items-center w-9 h-9 gap-1.5"
|
||||
aria-label="Ouvrir le menu"
|
||||
>
|
||||
<span className={`block w-6 h-0.5 bg-gray-700 transition-all duration-300 origin-center ${menuOpen ? 'rotate-45 translate-y-2' : ''}`} />
|
||||
<span className={`block w-6 h-0.5 bg-gray-700 transition-all duration-300 ${menuOpen ? 'opacity-0' : ''}`} />
|
||||
<span className={`block w-6 h-0.5 bg-gray-700 transition-all duration-300 origin-center ${menuOpen ? '-rotate-45 -translate-y-2' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Menu mobile déroulant */}
|
||||
{menuOpen && (
|
||||
<div className="md:hidden bg-white border-t border-gray-100 px-4 pb-4">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.to}
|
||||
to={link.to}
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className={`block py-3 font-medium border-b border-gray-50 ${
|
||||
pathname === link.to ? 'text-[#006B6B]' : 'text-gray-700'
|
||||
}`}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
<div className="flex flex-col gap-2 pt-3">
|
||||
{isLoggedIn ? (
|
||||
<>
|
||||
<Link
|
||||
to="/mes-gains"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="w-full text-center bg-[#006B6B] text-white px-4 py-2.5 rounded-lg text-sm font-medium"
|
||||
>
|
||||
Mon espace ({user.firstname})
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="w-full text-center border border-red-300 text-red-500 px-4 py-2.5 rounded-lg text-sm font-medium hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link
|
||||
to="/connexion"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="w-full text-center bg-[#006B6B] text-white px-4 py-2.5 rounded-lg hover:bg-[#005a5a] transition-colors text-sm font-medium"
|
||||
>
|
||||
Connexion
|
||||
</Link>
|
||||
<Link
|
||||
to="/inscription"
|
||||
onClick={() => setMenuOpen(false)}
|
||||
className="w-full text-center border border-[#006B6B] text-[#006B6B] px-4 py-2.5 rounded-lg hover:bg-[#006B6B] hover:text-white transition-colors text-sm font-medium"
|
||||
>
|
||||
Inscription
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
)
|
||||
}
|
||||
16
frontend/src/components/Layout.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Header from './Header'
|
||||
import Footer from './Footer'
|
||||
|
||||
export default function Layout({ children }) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
|
||||
<main className="flex-1">
|
||||
{children}
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
21
frontend/src/components/PasswordRules.jsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { PASSWORD_RULES } from '../utils/passwordRules'
|
||||
|
||||
// Règles du mot de passe, cochées au fil de la saisie. À relier au champ via
|
||||
// aria-describedby={id}.
|
||||
export default function PasswordRules({ id, password }) {
|
||||
return (
|
||||
<ul id={id} className="mt-2 space-y-1">
|
||||
{PASSWORD_RULES.map((rule) => {
|
||||
const ok = rule.test(password)
|
||||
return (
|
||||
<li key={rule.id} data-rule={rule.id} data-valid={ok}
|
||||
className={`flex items-center gap-2 text-xs ${ok ? 'text-green-700' : 'text-gray-500'}`}>
|
||||
<span aria-hidden="true">{ok ? '✓' : '○'}</span>
|
||||
{rule.label}
|
||||
<span className="sr-only">{ok ? ' : respecté' : ' : non respecté'}</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
23
frontend/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { homeFor, INITIAL_PASSWORD_PATH } from '../config/roles'
|
||||
|
||||
export default function ProtectedRoute({ children, roles }) {
|
||||
const { user } = useAuth()
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/connexion" replace />
|
||||
}
|
||||
|
||||
// Mot de passe provisoire : aucune page protégée tant qu'il n'est pas
|
||||
// remplacé (le backend refuse de toute façon les appels à l'API).
|
||||
if (user.mustChangePassword) {
|
||||
return <Navigate to={INITIAL_PASSWORD_PATH} replace />
|
||||
}
|
||||
|
||||
if (roles && !roles.includes(user.role)) {
|
||||
return <Navigate to={homeFor(user)} replace />
|
||||
}
|
||||
|
||||
return children
|
||||
}
|
||||
59
frontend/src/components/UserNav.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function UserNav({ active }) {
|
||||
const tabs = [
|
||||
{
|
||||
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" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
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" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
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" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex border-b border-gray-200 mb-6">
|
||||
{tabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.key}
|
||||
to={tab.to}
|
||||
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-500 border-transparent hover:text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
<span className="hidden sm:inline">{tab.label}</span>
|
||||
<span className="sm:hidden whitespace-nowrap">{tab.shortLabel}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
288
frontend/src/components/admin/EmployeeDeliveries.jsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { getEmployeesApi, getEmployeeDeliveriesApi, getDeliveryByTicketCodeApi } from '../../services/admin'
|
||||
|
||||
const STATUS_BADGES = {
|
||||
DELIVERED: { label: 'Lot remis', className: 'bg-green-100 text-green-700' },
|
||||
PENDING: { label: 'En attente de retrait', className: 'bg-amber-100 text-amber-700' },
|
||||
CANCELLED: { label: 'Expiré', className: 'bg-gray-200 text-gray-700' },
|
||||
NOT_PLAYED: { label: 'Pas encore joué', className: 'bg-gray-100 text-gray-600' },
|
||||
}
|
||||
|
||||
const INPUT_CLASS =
|
||||
'w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]'
|
||||
const BUTTON_CLASS =
|
||||
'bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm'
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
return new Date(dateStr).toLocaleString('fr-FR', {
|
||||
day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
const fullName = (person) => `${person.firstname} ${person.lastname}`
|
||||
|
||||
// Traçabilité des remises de lots : liste des employés, lots remis par chacun,
|
||||
// et recherche inverse par numéro de ticket.
|
||||
// `refreshKey` change quand un employé est créé, pour recharger la liste.
|
||||
export default function EmployeeDeliveries({ refreshKey, onAuthError }) {
|
||||
const [employees, setEmployees] = useState(null)
|
||||
const [employeesError, setEmployeesError] = useState('')
|
||||
const [filter, setFilter] = useState('')
|
||||
|
||||
const [selected, setSelected] = useState(null)
|
||||
const [deliveries, setDeliveries] = useState(null)
|
||||
const [deliveriesLoading, setDeliveriesLoading] = useState(false)
|
||||
const [deliveriesError, setDeliveriesError] = useState('')
|
||||
const detailRef = useRef(null)
|
||||
|
||||
const [ticketCode, setTicketCode] = useState('')
|
||||
const [ticketResult, setTicketResult] = useState(null)
|
||||
const [ticketLoading, setTicketLoading] = useState(false)
|
||||
const [ticketError, setTicketError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
getEmployeesApi()
|
||||
.then((data) => {
|
||||
setEmployees(data.employees)
|
||||
setEmployeesError('')
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!onAuthError(err)) setEmployeesError(err.message)
|
||||
})
|
||||
}, [refreshKey, onAuthError])
|
||||
|
||||
const openEmployee = async (employee) => {
|
||||
setSelected(employee)
|
||||
setDeliveries(null)
|
||||
setDeliveriesError('')
|
||||
setDeliveriesLoading(true)
|
||||
try {
|
||||
const data = await getEmployeeDeliveriesApi(employee.id)
|
||||
setDeliveries(data.deliveries)
|
||||
} catch (err) {
|
||||
if (!onAuthError(err)) setDeliveriesError(err.message)
|
||||
} finally {
|
||||
setDeliveriesLoading(false)
|
||||
detailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleTicketSearch = async (e) => {
|
||||
e.preventDefault()
|
||||
setTicketError('')
|
||||
setTicketResult(null)
|
||||
setTicketLoading(true)
|
||||
try {
|
||||
setTicketResult(await getDeliveryByTicketCodeApi(ticketCode))
|
||||
} catch (err) {
|
||||
if (!onAuthError(err)) setTicketError(err.message)
|
||||
} finally {
|
||||
setTicketLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const needle = filter.trim().toLowerCase()
|
||||
const visibleEmployees = (employees ?? []).filter((employee) =>
|
||||
!needle || `${fullName(employee)} ${employee.email}`.toLowerCase().includes(needle)
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6 space-y-6">
|
||||
<div>
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Employés et remises des lots</h2>
|
||||
<p className="text-xs sm:text-sm text-gray-500">
|
||||
Retrouvez quel employé a remis un lot et à quelle date, par numéro de ticket ou par employé.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Recherche par numéro de ticket ── */}
|
||||
<div>
|
||||
<form onSubmit={handleTicketSearch} className="flex flex-wrap items-end gap-3">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<label htmlFor="delivery-ticket-code" className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Numéro du ticket
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="delivery-ticket-code"
|
||||
value={ticketCode}
|
||||
onChange={(e) => setTicketCode(e.target.value)}
|
||||
required
|
||||
placeholder="Ex. A1B2C3D4E5"
|
||||
className={`${INPUT_CLASS} uppercase placeholder:normal-case`}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" disabled={ticketLoading || !ticketCode.trim()} className={BUTTON_CLASS}>
|
||||
{ticketLoading ? 'Recherche…' : 'Rechercher'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{ticketError && <p className="text-red-500 text-xs sm:text-sm mt-3">{ticketError}</p>}
|
||||
|
||||
{ticketResult && (
|
||||
<div className="mt-4 p-4 rounded-lg border border-gray-200 bg-gray-50 text-sm space-y-1.5" data-testid="ticket-result">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<p className="font-mono font-semibold text-gray-800">{ticketResult.ticketCode}</p>
|
||||
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${STATUS_BADGES[ticketResult.status]?.className}`}>
|
||||
{STATUS_BADGES[ticketResult.status]?.label ?? ticketResult.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-gray-600">Lot : <span className="font-medium text-gray-800">{ticketResult.lot.name}</span></p>
|
||||
{ticketResult.client && (
|
||||
<p className="text-gray-600">
|
||||
Gagné par : <span className="font-medium text-gray-800">{fullName(ticketResult.client)}</span>{' '}
|
||||
<span className="text-gray-500">({ticketResult.client.email})</span>
|
||||
{ticketResult.playedAt && <> le {formatDateTime(ticketResult.playedAt)}</>}
|
||||
</p>
|
||||
)}
|
||||
{ticketResult.deliveredBy ? (
|
||||
<p className="text-gray-600">
|
||||
Remis par :{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEmployee(ticketResult.deliveredBy)}
|
||||
className="font-semibold text-[#006B6B] hover:underline"
|
||||
>
|
||||
{fullName(ticketResult.deliveredBy)}
|
||||
</button>{' '}
|
||||
le <span className="font-medium text-gray-800">{formatDateTime(ticketResult.deliveredAt)}</span>
|
||||
</p>
|
||||
) : (
|
||||
ticketResult.status !== 'NOT_PLAYED' && (
|
||||
<p className="text-gray-500">Ce lot n'a pas encore été remis.</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Liste des employés ── */}
|
||||
<div className="pt-6 border-t border-gray-100">
|
||||
<div className="flex items-end justify-between flex-wrap gap-3 mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
Comptes employés
|
||||
{employees && <span className="text-gray-500 font-normal"> - {employees.length}</span>}
|
||||
</h3>
|
||||
<div className="w-full sm:w-64">
|
||||
<label htmlFor="employee-filter" className="sr-only">Rechercher un employé</label>
|
||||
<input
|
||||
type="search"
|
||||
id="employee-filter"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Rechercher un employé…"
|
||||
className={INPUT_CLASS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{employeesError && <p className="text-red-500 text-sm">{employeesError}</p>}
|
||||
{!employees && !employeesError && <p className="text-gray-500 text-sm">Chargement…</p>}
|
||||
|
||||
{employees && (
|
||||
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||
<table className="w-full text-sm min-w-[520px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
||||
<th className="pb-2 px-4 sm:px-0">Nom</th>
|
||||
<th className="pb-2 px-4 sm:px-0">Email</th>
|
||||
<th className="pb-2 px-4 sm:px-0">Créé le</th>
|
||||
<th className="pb-2 px-4 sm:px-0 text-center">Lots remis</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleEmployees.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-6 text-center text-gray-500 text-sm">
|
||||
{employees.length === 0 ? "Aucun compte employé pour l'instant" : 'Aucun employé ne correspond à la recherche'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{visibleEmployees.map((employee) => (
|
||||
<tr key={employee.id} className={`border-b last:border-0 ${selected?.id === employee.id ? 'bg-[#006B6B]/5' : ''}`}>
|
||||
<td className="py-3 px-4 sm:px-0 text-xs sm:text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEmployee(employee)}
|
||||
className="font-semibold text-[#006B6B] hover:underline text-left"
|
||||
>
|
||||
{fullName(employee)}
|
||||
</button>
|
||||
{employee.mustChangePassword && (
|
||||
<span className="block text-[11px] text-amber-700">Première connexion en attente</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">{employee.email}</td>
|
||||
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">
|
||||
{new Date(employee.createdAt).toLocaleDateString('fr-FR')}
|
||||
</td>
|
||||
<td className="py-3 px-4 sm:px-0 text-center font-semibold text-gray-800">{employee.deliveredCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Lots remis par l'employé sélectionné ── */}
|
||||
{selected && (
|
||||
<div ref={detailRef} className="pt-6 border-t border-gray-100">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2 mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-800">
|
||||
Lots remis par {fullName(selected)}
|
||||
{deliveries && <span className="text-gray-500 font-normal"> — {deliveries.length}</span>}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected(null)}
|
||||
className="border border-gray-200 text-gray-600 hover:bg-gray-50 font-semibold px-3 py-1.5 rounded-lg transition-colors text-xs"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{deliveriesLoading && <p className="text-gray-500 text-sm">Chargement…</p>}
|
||||
{deliveriesError && <p className="text-red-500 text-sm">{deliveriesError}</p>}
|
||||
|
||||
{deliveries && (
|
||||
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||
<table className="w-full text-sm min-w-[560px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
||||
<th className="pb-2 px-4 sm:px-0">Ticket</th>
|
||||
<th className="pb-2 px-4 sm:px-0">Lot</th>
|
||||
<th className="pb-2 px-4 sm:px-0">Client</th>
|
||||
<th className="pb-2 px-4 sm:px-0 text-right">Remis le</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{deliveries.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-6 text-center text-gray-500 text-sm">
|
||||
Cet employé n'a encore remis aucun lot
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{deliveries.map((delivery) => (
|
||||
<tr key={delivery.claimId} className="border-b last:border-0">
|
||||
<td className="py-3 px-4 sm:px-0 font-mono text-xs text-gray-800">{delivery.ticketCode}</td>
|
||||
<td className="py-3 px-4 sm:px-0 text-xs sm:text-sm text-gray-800">{delivery.lot.name}</td>
|
||||
<td className="py-3 px-4 sm:px-0 text-xs text-gray-500">
|
||||
{fullName(delivery.client)}
|
||||
<span className="block">{delivery.client.email}</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 sm:px-0 text-right text-xs text-gray-500 whitespace-nowrap">
|
||||
{formatDateTime(delivery.deliveredAt)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
frontend/src/config/captcha.js
Normal file
@@ -0,0 +1,8 @@
|
||||
export const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY
|
||||
|
||||
// Sans clé (tests, dev sans .env), aucun widget n'est affiché et aucun jeton
|
||||
// n'est exigé côté front ; le backend fait de même sans TURNSTILE_SECRET_KEY.
|
||||
// navigator.webdriver : le pré-rendu Playwright (scripts/prerender.js) ne doit
|
||||
// pas figer l'iframe Cloudflare dans le HTML statique.
|
||||
export const CAPTCHA_ENABLED =
|
||||
Boolean(TURNSTILE_SITE_KEY) && !(typeof navigator !== 'undefined' && navigator.webdriver)
|
||||
10
frontend/src/config/roles.js
Normal file
@@ -0,0 +1,10 @@
|
||||
// Page d'accueil de chaque rôle après connexion.
|
||||
export const HOME_BY_ROLE = { ADMIN: '/admin', EMPLOYEE: '/employe', CLIENT: '/mes-gains' }
|
||||
|
||||
// Page imposée tant qu'un mot de passe provisoire n'a pas été remplacé.
|
||||
export const INITIAL_PASSWORD_PATH = '/nouveau-mot-de-passe'
|
||||
|
||||
export function homeFor(user) {
|
||||
if (user?.mustChangePassword) return INITIAL_PASSWORD_PATH
|
||||
return HOME_BY_ROLE[user?.role] || '/connexion'
|
||||
}
|
||||
33
frontend/src/context/AuthContext.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
|
||||
import { createContext, useContext, useState } from 'react'
|
||||
|
||||
const AuthContext = createContext(null)
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(() =>
|
||||
JSON.parse(localStorage.getItem('user') || 'null')
|
||||
)
|
||||
|
||||
function login(userData, token) {
|
||||
localStorage.setItem('token', token)
|
||||
localStorage.setItem('user', JSON.stringify(userData))
|
||||
setUser(userData)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
setUser(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext)
|
||||
}
|
||||
26
frontend/src/hooks/useGameDates.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { getGameWindowApi } from '../services/game'
|
||||
import { toGameDates } from '../utils/gameDates'
|
||||
|
||||
// Dates du jeu-concours pour l'affichage public. Rend immédiatement les dates
|
||||
// par défaut (2 → 31 octobre 2026), puis celles fixées par l'admin dès que
|
||||
// l'API répond. Rechargées à chaque montage : une date modifiée dans le
|
||||
// tableau de bord apparaît dès le retour sur la page.
|
||||
export default function useGameDates() {
|
||||
const [gameWindow, setGameWindow] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
getGameWindowApi()
|
||||
.then((data) => {
|
||||
if (active) setGameWindow(data)
|
||||
})
|
||||
// API injoignable : les dates par défaut restent affichées.
|
||||
.catch(() => {})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return useMemo(() => toGameDates(gameWindow), [gameWindow])
|
||||
}
|
||||
17
frontend/src/hooks/useHandleAuthError.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
export function useHandleAuthError() {
|
||||
const { logout } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
return useCallback((err) => {
|
||||
if (err.status === 401) {
|
||||
logout()
|
||||
navigate('/connexion')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}, [logout, navigate])
|
||||
}
|
||||
50
frontend/src/hooks/usePageMeta.js
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
const SITE_URL = 'https://www.dsp5-archi-o24a-4-5-g4.fr'
|
||||
|
||||
function setAttr(selector, attr, value) {
|
||||
if (value == null) return null
|
||||
|
||||
const el = document.querySelector(selector)
|
||||
if (!el) return null
|
||||
|
||||
const previous = el.getAttribute(attr)
|
||||
el.setAttribute(attr, value)
|
||||
|
||||
return () => {
|
||||
if (previous == null) {
|
||||
el.removeAttribute(attr)
|
||||
} else {
|
||||
el.setAttribute(attr, previous)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function usePageMeta({ title, description, image } = {}) {
|
||||
const { pathname } = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
if (title) {
|
||||
document.title = title
|
||||
}
|
||||
|
||||
const url = `${SITE_URL}${pathname}`
|
||||
|
||||
const restores = [
|
||||
setAttr('meta[name="description"]', 'content', description),
|
||||
setAttr('link[rel="canonical"]', 'href', url),
|
||||
setAttr('meta[property="og:title"]', 'content', title),
|
||||
setAttr('meta[property="og:description"]', 'content', description),
|
||||
setAttr('meta[property="og:url"]', 'content', url),
|
||||
setAttr('meta[property="og:image"]', 'content', image),
|
||||
setAttr('meta[name="twitter:title"]', 'content', title),
|
||||
setAttr('meta[name="twitter:description"]', 'content', description),
|
||||
setAttr('meta[name="twitter:image"]', 'content', image),
|
||||
].filter(Boolean)
|
||||
|
||||
return () => {
|
||||
restores.forEach((restore) => restore())
|
||||
}
|
||||
}, [title, description, image, pathname])
|
||||
}
|
||||
@@ -1 +1,54 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Animation d'entrée de la bannière d'accueil : l'illustration glisse
|
||||
légèrement depuis la droite en grossissant jusqu'à sa taille finale, et
|
||||
les cartes de gains apparaissent juste après avec un léger décalage
|
||||
(delay défini au cas par cas via [animation-delay:...]). */
|
||||
@keyframes banner-image-enter {
|
||||
0% { opacity: 0; transform: translateX(70px) scale(0.9); }
|
||||
100% { opacity: 1; transform: translateX(0) scale(1); }
|
||||
}
|
||||
@keyframes banner-badge-enter {
|
||||
0% { opacity: 0; transform: translateY(16px) scale(0.9); }
|
||||
100% { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
/* Flottement continu des cartes de la bannière, une fois qu'elles sont
|
||||
apparues (deux variantes légèrement différentes pour éviter un
|
||||
mouvement trop synchronisé entre les deux cartes). Appliqué sur un
|
||||
conteneur séparé de celui qui gère l'inclinaison au survol (tilt), pour
|
||||
que les deux animations du `transform` ne se marchent pas dessus. */
|
||||
@keyframes card-float-a {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-10px) rotate(1.5deg); }
|
||||
}
|
||||
@keyframes card-float-b {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-7px) rotate(-1.5deg); }
|
||||
}
|
||||
|
||||
/* Dérive douce des particules de thé générées en JS (position, taille,
|
||||
durée et délai tirés au sort côté composant, amplitude reçue ici via la
|
||||
variable CSS --tea-drift posée en style inline par particule). */
|
||||
@keyframes tea-drift {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(calc(-1 * var(--tea-drift, 12px))) rotate(8deg); }
|
||||
}
|
||||
|
||||
.animate-banner-image { animation: banner-image-enter 900ms cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||
.animate-banner-badge { animation: banner-badge-enter 600ms cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||
.animate-card-float-a { animation: card-float-a 4.5s ease-in-out infinite; }
|
||||
.animate-card-float-b { animation: card-float-b 5.5s ease-in-out infinite; }
|
||||
.animate-tea-drift { animation: tea-drift 6s ease-in-out infinite; }
|
||||
|
||||
/* Respecte les préférences d'accessibilité : pas d'animation pour les
|
||||
personnes qui ont désactivé les mouvements dans leur OS. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-banner-image,
|
||||
.animate-banner-badge,
|
||||
.animate-card-float-a,
|
||||
.animate-card-float-b,
|
||||
.animate-tea-drift {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
48
frontend/src/pages/Accessibilite.jsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import usePageMeta from '../hooks/usePageMeta'
|
||||
|
||||
export default function Accessibilite() {
|
||||
usePageMeta({
|
||||
title: 'Déclaration d’accessibilité | Thé Tip Top',
|
||||
description: "État de conformité RGAA du site Thé Tip Top et procédure pour signaler un problème d'accessibilité rencontré sur le site.",
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||
Déclaration d'accessibilité
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-600 mb-8 sm:mb-10 leading-relaxed text-sm sm:text-base">
|
||||
La société Thé Tip Top s'engage à rendre le site de son jeu-concours « 100% Gagnant »
|
||||
accessible conformément à l'article 47 de la loi n° 2005-102 du 11 février 2005.
|
||||
</p>
|
||||
|
||||
<section className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
État de conformité
|
||||
</h2>
|
||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||
Le site a été développé en conformité avec les normes d'accessibilité internationales
|
||||
WCAG 2.1 niveau AA et le Référentiel Général d'Amélioration de l'Accessibilité
|
||||
(RGAA 4.1) afin de garantir une accessibilité maximale aux utilisateurs en situation
|
||||
de handicap.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
Signaler un problème d'accessibilité
|
||||
</h2>
|
||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||
Si vous rencontrez un obstacle à l'accessibilité sur le site, vous pouvez nous en faire
|
||||
part afin que nous puissions y remédier :<br />
|
||||
<strong>Email :</strong> thetiptop.gp4@gmail.com
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
273
frontend/src/pages/Accueil.jsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import usePageMeta from '../hooks/usePageMeta'
|
||||
import useGameDates from '../hooks/useGameDates'
|
||||
import { formatDay, formatRange } from '../utils/gameDates'
|
||||
import bannerImg from '../assets/img/banner-accueil.webp'
|
||||
|
||||
const gains = [
|
||||
{ label: 'Infuseur à thé', top: '26%', right: '26%', delay: 700, variant: 'a' },
|
||||
{ label: 'Boîte 100 g détox ou infusion', top: '22%', right: '1%', delay: 850, variant: 'b' },
|
||||
{ label: 'Boîte 100 g thé signature', top: '48%', right: '30%', delay: 1000, variant: 'a' },
|
||||
{ label: 'Coffret découverte 39 €', top: '78%', right: '2%', delay: 1150, variant: 'b' },
|
||||
{ label: 'Coffret découverte 69 €', top: '84%', right: '24%', delay: 1300, variant: 'a' },
|
||||
]
|
||||
|
||||
function handleCardTilt(e) {
|
||||
const el = e.currentTarget
|
||||
const rect = el.getBoundingClientRect()
|
||||
const px = (e.clientX - rect.left) / rect.width - 0.5
|
||||
const py = (e.clientY - rect.top) / rect.height - 0.5
|
||||
el.style.transform = `perspective(700px) rotateX(${(-py * 12).toFixed(2)}deg) rotateY(${(px * 12).toFixed(2)}deg) scale(1.04)`
|
||||
}
|
||||
function resetCardTilt(e) {
|
||||
e.currentTarget.style.transform = ''
|
||||
}
|
||||
|
||||
const TEA_EMOJI = ['🍃', '🍵', '🫖', '☕', '🥄', '✨']
|
||||
|
||||
function createTeaParticles(count) {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
id: i,
|
||||
emoji: TEA_EMOJI[i % TEA_EMOJI.length],
|
||||
left: Math.round(Math.random() * 100),
|
||||
top: Math.round(Math.random() * 100),
|
||||
size: Math.round(20 + Math.random() * 26),
|
||||
duration: +(4 + Math.random() * 4).toFixed(1),
|
||||
delay: +(Math.random() * 4).toFixed(1),
|
||||
opacity: +(0.18 + Math.random() * 0.22).toFixed(2),
|
||||
drift: Math.round(12 + Math.random() * 20),
|
||||
}))
|
||||
}
|
||||
|
||||
// Les dates viennent du tableau de bord admin (voir useGameDates).
|
||||
const buildFaqs = ({ start, lastPlayDay, lastClaimDay }) => [
|
||||
{
|
||||
question: 'Comment obtenir un code ?',
|
||||
answer: `Achetez pour plus de 49 € dans notre boutique Thé Tip Top de Nice, du ${formatRange(start, lastPlayDay)} : un code unique à 10 caractères figure sur votre ticket de caisse ou votre facture.`,
|
||||
},
|
||||
{
|
||||
question: 'Quels sont les lots à gagner ?',
|
||||
answer: (
|
||||
<>
|
||||
Tous les tickets sont gagnants ! Selon votre ticket, vous pouvez remporter :
|
||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
||||
<li>Un infuseur à thé</li>
|
||||
<li>Une boîte de 100 g de thé détox ou d'infusion</li>
|
||||
<li>Une boîte de 100 g de thé signature</li>
|
||||
<li>Un coffret découverte d'une valeur de 39 €</li>
|
||||
<li>Un coffret découverte d'une valeur de 69 €</li>
|
||||
</ul>
|
||||
Un tirage au sort désigne en plus un grand gagnant qui remporte une année de thé d'une valeur de 360 €.
|
||||
</>
|
||||
),
|
||||
plainAnswer: "Tous les tickets sont gagnants ! Selon votre ticket, vous pouvez remporter : un infuseur à thé, une boîte de 100 g de thé détox ou d'infusion, une boîte de 100 g de thé signature, un coffret découverte d'une valeur de 39 €, ou un coffret découverte d'une valeur de 69 €. Un tirage au sort désigne en plus un grand gagnant qui remporte une année de thé d'une valeur de 360 €.",
|
||||
},
|
||||
{
|
||||
question: 'Jusqu\'à quand puis-je jouer ?',
|
||||
answer: `La saisie des codes est ouverte du ${formatRange(start, lastPlayDay)} inclus. Les lots sont ensuite à retirer jusqu'au ${formatDay(lastClaimDay)}.`,
|
||||
},
|
||||
{
|
||||
question: 'Que faire si mon code n\'est pas reconnu ?',
|
||||
answer: "Vérifiez que le code a été saisi sans espace ni erreur. Si le problème persiste, contactez-nous depuis la page Contact.",
|
||||
},
|
||||
{
|
||||
question: 'Où retirer mon lot ?',
|
||||
answer: `Dans notre boutique de Nice, sur présentation de votre compte utilisateur validé et de la confirmation de gain affichée dans votre espace « Mes gains », jusqu'au ${formatDay(lastClaimDay)}.`,
|
||||
},
|
||||
]
|
||||
|
||||
const buildFaqJsonLd = (faqs) => ({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqs.map((faq) => ({
|
||||
'@type': 'Question',
|
||||
name: faq.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: faq.plainAnswer || faq.answer,
|
||||
},
|
||||
})),
|
||||
})
|
||||
|
||||
export default function Accueil() {
|
||||
const gameDates = useGameDates()
|
||||
const period = formatRange(gameDates.start, gameDates.lastPlayDay)
|
||||
const faqs = useMemo(() => buildFaqs(gameDates), [gameDates])
|
||||
const faqJsonLd = useMemo(() => buildFaqJsonLd(faqs), [faqs])
|
||||
const bannerAlt = `Jeu-concours 100 % Gagnant Thé Tip Top, du ${period} : tentez de gagner un an de thé d'une valeur de 360 € pour l'ouverture de notre 10e boutique à Nice.`
|
||||
|
||||
usePageMeta({
|
||||
title: `Jeu-concours 100 % Gagnant du ${formatRange(gameDates.start, gameDates.lastPlayDay, { withYear: false })} | Thé Tip Top`,
|
||||
description: `Du ${formatRange(gameDates.start, gameDates.lastPlayDay, { withYear: false })}, tout ticket de plus de 49 € de notre boutique de Nice porte un code 100 % gagnant. Saisissez le vôtre et repartez avec votre lot.`,
|
||||
})
|
||||
|
||||
const teaParticles = useMemo(() => createTeaParticles(30), [])
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 min-h-screen">
|
||||
|
||||
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }} />
|
||||
|
||||
<section className="relative bg-[#006B6B] overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 relative py-10 lg:py-0 lg:h-[680px] xl:h-[780px]">
|
||||
|
||||
<div
|
||||
className="hidden lg:block absolute right-[-10%] top-1/2 -translate-y-1/2 w-[75%] h-[75%] rounded-full bg-white/10 blur-3xl"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div className="pointer-events-none absolute inset-0 overflow-hidden z-0" aria-hidden="true">
|
||||
{teaParticles.map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className="absolute animate-tea-drift select-none"
|
||||
style={{
|
||||
left: `${p.left}%`,
|
||||
top: `${p.top}%`,
|
||||
fontSize: `${p.size}px`,
|
||||
opacity: p.opacity,
|
||||
animationDuration: `${p.duration}s`,
|
||||
animationDelay: `${p.delay}s`,
|
||||
'--tea-drift': `${p.drift}px`,
|
||||
}}
|
||||
>
|
||||
{p.emoji}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="static lg:absolute lg:left-0 lg:top-10 max-w-full lg:max-w-md mb-6 lg:mb-0">
|
||||
<p className="inline-block text-sm sm:text-base font-bold uppercase tracking-widest text-amber-300 border border-white/70 rounded-lg px-3.5 py-2 mb-3">
|
||||
Du {formatRange(gameDates.start, gameDates.lastPlayDay, { padDay: true })}
|
||||
</p>
|
||||
<h1 className="text-3xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-[1.05]">
|
||||
Jeu-concours{' '}
|
||||
<span className="text-amber-300">100 % Gagnant</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="relative -mx-4 sm:mx-0 lg:mx-0 mb-6 lg:mb-0 lg:contents">
|
||||
<div className="relative aspect-[16/10] sm:aspect-[21/9] overflow-hidden lg:hidden">
|
||||
<img
|
||||
src={bannerImg}
|
||||
alt={bannerAlt}
|
||||
className="absolute inset-y-0 right-0 w-[210%] sm:w-[130%] max-w-none h-full object-cover object-right animate-banner-image"
|
||||
/>
|
||||
</div>
|
||||
<img
|
||||
src={bannerImg}
|
||||
alt={bannerAlt}
|
||||
width={2048}
|
||||
height={1152}
|
||||
className="hidden lg:block lg:absolute lg:right-[-2%] lg:top-1/2 lg:-translate-y-1/2 lg:h-[145%] xl:h-[160%] lg:w-auto lg:max-w-none object-contain drop-shadow-2xl animate-banner-image"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className="static lg:absolute lg:left-0 lg:top-1/2 lg:-translate-y-1/2 max-w-full lg:max-w-md text-lg sm:text-2xl lg:text-3xl font-semibold text-white leading-snug [text-wrap:balance] mb-6 lg:mb-0 bg-white/10 backdrop-blur-sm border-l-4 border-amber-300 rounded-2xl px-4 py-3 sm:px-6 sm:py-4 shadow-lg">
|
||||
À l'occasion de l'ouverture de notre{' '}
|
||||
<span className="text-amber-300">10<sup>e</sup> boutique</span> à Nice : chaque code
|
||||
distribué correspond à un lot réel, attribué à l'avance.
|
||||
</p>
|
||||
|
||||
<div className="hidden lg:contents">
|
||||
|
||||
<div className="shrink-0 lg:absolute lg:right-[1%] lg:top-[45%] animate-banner-badge [animation-delay:500ms]">
|
||||
<div className="animate-card-float-a">
|
||||
<div
|
||||
onMouseMove={handleCardTilt}
|
||||
onMouseLeave={resetCardTilt}
|
||||
className="bg-white rounded-2xl shadow-xl px-5 sm:px-7 py-4 sm:py-5 text-center transition-transform duration-200 ease-out will-change-transform cursor-default"
|
||||
>
|
||||
<p className="text-3xl sm:text-4xl lg:text-5xl font-extrabold text-gray-900 leading-none">
|
||||
360 <span className="text-[#006B6B]">€</span>
|
||||
</p>
|
||||
<p className="text-xs sm:text-sm font-semibold text-gray-800 whitespace-nowrap mt-1.5">de thé à gagner</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{gains.map((gain) => (
|
||||
<div
|
||||
key={gain.label}
|
||||
className="shrink-0 lg:absolute animate-banner-badge"
|
||||
style={{ top: gain.top, right: gain.right, animationDelay: `${gain.delay}ms` }}
|
||||
>
|
||||
<div className={gain.variant === 'a' ? 'animate-card-float-a' : 'animate-card-float-b'}>
|
||||
<div
|
||||
onMouseMove={handleCardTilt}
|
||||
onMouseLeave={resetCardTilt}
|
||||
className="bg-white rounded-2xl shadow-xl px-3.5 py-2.5 text-left transition-transform duration-200 ease-out will-change-transform cursor-default"
|
||||
>
|
||||
<span className="text-xs sm:text-sm font-semibold text-gray-800 whitespace-nowrap">{gain.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="static lg:absolute lg:left-0 lg:bottom-24 xl:bottom-28 flex flex-col sm:flex-row gap-3 sm:gap-4">
|
||||
<Link
|
||||
to="/saisir-code-unique"
|
||||
className="bg-white hover:bg-gray-100 text-[#006B6B] font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base text-center"
|
||||
>
|
||||
Tester mon code
|
||||
</Link>
|
||||
<Link
|
||||
to="/regles-jeu"
|
||||
className="border-2 border-white text-white hover:bg-white hover:text-[#006B6B] font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base text-center"
|
||||
>
|
||||
Découvrir les règles
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="max-w-5xl mx-auto px-4 pt-6 sm:px-6 pb-12 sm:pb-20">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center">
|
||||
Comment participer ?
|
||||
</h2>
|
||||
<ol className="grid grid-cols-1 sm:grid-cols-3 gap-4 list-none">
|
||||
{[
|
||||
{ num: '1', text: "Achat de 49 € dans notre boutique de Nice, code sur le ticket" },
|
||||
{ num: '2', text: 'Créez votre compte et saisissez le code' },
|
||||
{ num: '3', text: 'Découvrez et retirez votre lot en boutique' },
|
||||
].map((step) => (
|
||||
<li key={step.num} className="bg-white rounded-2xl shadow p-4 sm:p-6 text-center">
|
||||
<div className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-2 sm:mb-3">{step.num}</div>
|
||||
<p className="text-sm sm:text-base text-gray-700">{step.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section className="max-w-3xl mx-auto px-4 sm:px-6 pb-16 sm:pb-24">
|
||||
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center">
|
||||
Questions fréquentes
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{faqs.map((faq) => (
|
||||
<details key={faq.question} className="group bg-white rounded-2xl shadow p-4 sm:p-6">
|
||||
<summary className="flex items-center justify-between gap-3 cursor-pointer list-none font-semibold text-gray-800 text-sm sm:text-base">
|
||||
{faq.question}
|
||||
<svg
|
||||
className="w-4 h-4 text-[#006B6B] flex-shrink-0 transition-transform group-open:rotate-180"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</summary>
|
||||
<div className="text-gray-600 text-sm sm:text-base mt-3 leading-relaxed">
|
||||
{faq.answer}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +1,638 @@
|
||||
const MOCK_STATS = {
|
||||
totalParticipants: 1248,
|
||||
gainsRemis: 342,
|
||||
gainsEnAttente: 906,
|
||||
tauxParticipation: '78%',
|
||||
import { useState, useEffect } from 'react'
|
||||
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi, createEmployeeApi } from '../services/admin'
|
||||
import EmployeeDeliveries from '../components/admin/EmployeeDeliveries'
|
||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||
import { formatDay, formatRange, gameDatesFromStart } from '../utils/gameDates'
|
||||
|
||||
function StatCard({ label, value, sub }) {
|
||||
return (
|
||||
<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-500 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MOCK_USERS = [
|
||||
{ id: 1, name: 'Jean Dupont', email: 'jean.dupont@email.com', gains: 2 },
|
||||
{ id: 2, name: 'Marie Martin', email: 'marie.martin@email.com', gains: 1 },
|
||||
{ id: 3, name: 'Paul Bernard', email: 'paul.bernard@email.com', gains: 3 },
|
||||
]
|
||||
// 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é',
|
||||
}
|
||||
|
||||
// Valeur attendue par <input type="datetime-local"> : heure locale, sans fuseau.
|
||||
function toDateTimeLocalValue(dateStr) {
|
||||
const date = new Date(dateStr)
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: '2-digit', month: 'long', year: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export default function Admin() {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-800 mb-6">Tableau de bord Admin</h1>
|
||||
const handleAuthError = useHandleAuthError()
|
||||
const [stats, setStats] = useState(null)
|
||||
const [users, setUsers] = useState([])
|
||||
const [winner, setWinner] = useState(undefined)
|
||||
const [drawing, setDrawing] = useState(false)
|
||||
const [drawError, setDrawError] = useState('')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
// Lancement du jeu-concours
|
||||
const [startDateInput, setStartDateInput] = useState('')
|
||||
const [settingsSaving, setSettingsSaving] = useState(false)
|
||||
const [settingsError, setSettingsError] = useState('')
|
||||
const [settingsOk, setSettingsOk] = useState('')
|
||||
|
||||
// Contacts emailing
|
||||
const [emailingContacts, setEmailingContacts] = useState(null)
|
||||
const [emailingLoading, setEmailingLoading] = useState(false)
|
||||
const [emailingError, setEmailingError] = useState('')
|
||||
|
||||
// Création d'un compte employé
|
||||
const [employeeForm, setEmployeeForm] = useState({ firstname: '', lastname: '', email: '' })
|
||||
const [employeeSaving, setEmployeeSaving] = useState(false)
|
||||
const [employeeError, setEmployeeError] = useState('')
|
||||
const [employeeOk, setEmployeeOk] = useState('')
|
||||
// Incrémenté à chaque création pour recharger la liste des employés.
|
||||
const [employeesVersion, setEmployeesVersion] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
|
||||
.then(([s, u, w]) => {
|
||||
setStats(s)
|
||||
// Le champ part de la date enregistrée, celle affichée sur l'accueil.
|
||||
if (s.gameWindow?.startDate) setStartDateInput(toDateTimeLocalValue(s.gameWindow.startDate))
|
||||
setUsers(u.users)
|
||||
setWinner(w)
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!handleAuthError(e)) setError(e.message)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [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} La page d'accueil affiche désormais ces dates.`)
|
||||
setStartDateInput(toDateTimeLocalValue(data.gameWindow.startDate))
|
||||
} catch (err) {
|
||||
if (!handleAuthError(err)) setSettingsError(err.message)
|
||||
} finally {
|
||||
setSettingsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDraw = async () => {
|
||||
setShowConfirm(false)
|
||||
setDrawing(true)
|
||||
setDrawError('')
|
||||
try {
|
||||
const data = await drawWinnerApi()
|
||||
setWinner(data)
|
||||
} catch (e) {
|
||||
if (!handleAuthError(e)) setDrawError(e.message)
|
||||
} finally {
|
||||
setDrawing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadEmailingContacts = async () => {
|
||||
setEmailingLoading(true)
|
||||
setEmailingError('')
|
||||
try {
|
||||
const data = await getEmailingContactsApi()
|
||||
setEmailingContacts(data.contacts)
|
||||
} catch (e) {
|
||||
if (!handleAuthError(e)) setEmailingError(e.message)
|
||||
} finally {
|
||||
setEmailingLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExportEmailingCsv = () => {
|
||||
if (!emailingContacts?.length) return
|
||||
const header = ['Prénom', 'Nom', 'Email', 'Inscrit le']
|
||||
const rows = emailingContacts.map((c) => [
|
||||
c.firstname, c.lastname, c.email, new Date(c.createdAt).toLocaleDateString('fr-FR'),
|
||||
])
|
||||
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;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `contacts-emailing-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleCreateEmployee = async (e) => {
|
||||
e.preventDefault()
|
||||
setEmployeeError('')
|
||||
setEmployeeOk('')
|
||||
setEmployeeSaving(true)
|
||||
try {
|
||||
const data = await createEmployeeApi(employeeForm)
|
||||
setEmployeeOk(`Compte créé pour ${data.employee.firstname} ${data.employee.lastname} : un email avec un mot de passe provisoire a été envoyé à ${data.employee.email}.`)
|
||||
setEmployeeForm({ firstname: '', lastname: '', email: '' })
|
||||
setEmployeesVersion((v) => v + 1)
|
||||
} catch (err) {
|
||||
if (!handleAuthError(err)) setEmployeeError(err.message)
|
||||
} finally {
|
||||
setEmployeeSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<p className="text-gray-500">Chargement...</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (error) return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center px-4">
|
||||
<p className="text-red-500 text-sm">{error}</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
const winnerUser = winner?.winner?.user
|
||||
|
||||
// Aperçu en direct des dates publiques pour la date saisie.
|
||||
const startInputDate = startDateInput ? new Date(startDateInput) : null
|
||||
const startPreview = startInputDate && !Number.isNaN(startInputDate.getTime())
|
||||
? gameDatesFromStart(startInputDate)
|
||||
: null
|
||||
const savedStart = stats?.gameWindow?.startDate
|
||||
const startPreviewUnsaved = Boolean(startPreview) &&
|
||||
(!savedStart || toDateTimeLocalValue(savedStart) !== startDateInput)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-800">Tableau de bord Admin</h1>
|
||||
|
||||
{/* Fenêtre du jeu-concours */}
|
||||
{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">
|
||||
{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 ${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-col sm:flex-row sm:items-end gap-3"
|
||||
>
|
||||
<div className="w-full sm:flex-1 sm: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="w-full sm:w-auto 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 whitespace-nowrap"
|
||||
>
|
||||
{settingsSaving ? 'Enregistrement…' : stats.gameWindow.configured ? 'Mettre à jour' : 'Lancer le jeu'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{startPreview && (
|
||||
<p className="text-xs text-gray-700 mt-3" data-testid="homepage-dates-preview">
|
||||
Affiché sur la page d'accueil :{' '}
|
||||
<span className="font-semibold">
|
||||
Du {formatRange(startPreview.start, startPreview.lastPlayDay, { padDay: true })}
|
||||
</span>
|
||||
, retrait des lots jusqu'au <span className="font-semibold">{formatDay(startPreview.lastClaimDay)}</span>
|
||||
{startPreviewUnsaved && <span className="text-amber-700"> (non enregistré)</span>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.
|
||||
</p>
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Stats principales */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 sm:gap-4">
|
||||
<StatCard label="Participants" value={stats?.users} />
|
||||
<StatCard label="Participations" value={stats?.participations} />
|
||||
<StatCard label="Gains en attente" value={stats?.claimsPending} />
|
||||
<StatCard label="Gains remis" value={stats?.claimsDelivered} />
|
||||
</div>
|
||||
|
||||
{/* Stats tickets */}
|
||||
<div className="grid grid-cols-3 gap-3 sm:gap-4">
|
||||
<StatCard label="Tickets total" value={stats?.ticketsTotal} />
|
||||
<StatCard label="Tickets utilisés" value={stats?.ticketsUsed} />
|
||||
<StatCard label="Tickets restants" value={stats?.ticketsRemaining} />
|
||||
</div>
|
||||
|
||||
{/* Stats démographiques */}
|
||||
{stats?.genderStats && stats?.ageStats && (
|
||||
<div className="grid sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<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">Genre des participants</h2>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ label: 'Participants', value: MOCK_STATS.totalParticipants },
|
||||
{ label: 'Gains remis', value: MOCK_STATS.gainsRemis },
|
||||
{ label: 'En attente', value: MOCK_STATS.gainsEnAttente },
|
||||
{ label: 'Taux participation', value: MOCK_STATS.tauxParticipation },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="bg-white rounded-xl shadow p-4 text-center">
|
||||
<p className="text-2xl font-bold text-green-700">{stat.value}</p>
|
||||
<p className="text-sm text-gray-500 mt-1">{stat.label}</p>
|
||||
{ label: 'Femmes', value: stats.genderStats.women },
|
||||
{ label: 'Hommes', value: stats.genderStats.men },
|
||||
{ label: 'Autre / non renseigné', value: stats.genderStats.other },
|
||||
].map((row) => (
|
||||
<div key={row.label} className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">{row.label}</span>
|
||||
<span className="font-semibold text-gray-800">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tirage au sort */}
|
||||
<div className="bg-white rounded-xl shadow p-6 mb-6">
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-3">Grand tirage au sort</h2>
|
||||
<button
|
||||
onClick={() => alert('TODO: connecter au backend')}
|
||||
className="bg-green-700 hover:bg-green-800 text-white font-semibold px-6 py-2.5 rounded-lg transition-colors"
|
||||
>
|
||||
Lancer le tirage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Liste participants */}
|
||||
<div className="bg-white rounded-xl shadow p-6">
|
||||
<h2 className="text-lg font-bold text-gray-800 mb-4">Participants</h2>
|
||||
<table className="w-full text-sm">
|
||||
<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">Âge des participants</h2>
|
||||
<div className="space-y-2">
|
||||
{['-18', '18-24', '25-34', '35-44', '45-54', '55-64', '65+', 'unknown'].map((bracket) => (
|
||||
<div key={bracket} className="flex items-center justify-between text-sm">
|
||||
<span className="text-gray-500">{bracket === 'unknown' ? 'Non renseigné' : `${bracket} ans`}</span>
|
||||
<span className="font-semibold text-gray-800">{stats.ageStats[bracket] ?? 0}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Répartition par lot */}
|
||||
{stats?.lotsStats?.length > 0 && (
|
||||
<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">Répartition des lots</h2>
|
||||
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||
<table className="w-full text-sm min-w-[480px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b">
|
||||
<th className="pb-2">Nom</th>
|
||||
<th className="pb-2">Email</th>
|
||||
<th className="pb-2 text-right">Gains</th>
|
||||
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
||||
<th className="pb-2 px-4 sm:px-0">Lot</th>
|
||||
<th className="pb-2 px-4 sm:px-0 text-center">%</th>
|
||||
<th className="pb-2 px-4 sm:px-0 text-center">Tickets</th>
|
||||
<th className="pb-2 px-4 sm:px-0 text-center">Participations</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MOCK_USERS.map((user) => (
|
||||
<tr key={user.id} className="border-b last:border-0">
|
||||
<td className="py-3 font-medium text-gray-800">{user.name}</td>
|
||||
<td className="py-3 text-gray-500">{user.email}</td>
|
||||
<td className="py-3 text-right text-green-700 font-semibold">{user.gains}</td>
|
||||
{stats.lotsStats.map((lot) => (
|
||||
<tr key={lot.id} className="border-b last:border-0">
|
||||
<td className="py-2.5 px-4 sm:px-0 font-medium text-gray-800 text-xs sm:text-sm">{lot.name}</td>
|
||||
<td className="py-2.5 px-4 sm:px-0 text-center text-gray-500 text-xs sm:text-sm">{lot.percentage}%</td>
|
||||
<td className="py-2.5 px-4 sm:px-0 text-center text-gray-600 text-xs sm:text-sm">{lot._count.tickets.toLocaleString('fr-FR')}</td>
|
||||
<td className="py-2.5 px-4 sm:px-0 text-center text-[#006B6B] font-semibold text-xs sm:text-sm">{lot._count.participations}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Contacts emailing ── */}
|
||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||
<div className="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-800">Contacts emailing</h2>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleLoadEmailingContacts}
|
||||
disabled={emailingLoading}
|
||||
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"
|
||||
>
|
||||
{emailingLoading ? 'Chargement…' : emailingContacts ? 'Actualiser' : 'Charger les contacts'}
|
||||
</button>
|
||||
{emailingContacts?.length > 0 && (
|
||||
<button
|
||||
onClick={handleExportEmailingCsv}
|
||||
className="border border-gray-200 text-gray-600 hover:bg-gray-50 font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
|
||||
>
|
||||
Exporter en CSV
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{emailingError && <p className="text-red-500 text-sm mb-3">{emailingError}</p>}
|
||||
|
||||
<p className="text-sm text-gray-500 mb-3">
|
||||
Clients ayant accepté d'être contactés à des fins marketing.
|
||||
{emailingContacts && <> — <span className="font-semibold text-gray-800">{emailingContacts.length}</span> contact(s)</>}
|
||||
</p>
|
||||
|
||||
{emailingContacts && (
|
||||
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||
<table className="w-full text-sm min-w-[400px]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
||||
<th className="pb-2 px-4 sm:px-0">Nom</th>
|
||||
<th className="pb-2 px-4 sm:px-0">Email</th>
|
||||
<th className="pb-2 px-4 sm:px-0 text-right">Inscrit le</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{emailingContacts.length === 0 && (
|
||||
<tr>
|
||||
<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>
|
||||
)}
|
||||
{emailingContacts.map((contact) => (
|
||||
<tr key={contact.email} className="border-b last:border-0">
|
||||
<td className="py-3 px-4 sm:px-0 font-medium text-gray-800 whitespace-nowrap text-xs sm:text-sm">
|
||||
{contact.firstname} {contact.lastname}
|
||||
</td>
|
||||
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">{contact.email}</td>
|
||||
<td className="py-3 px-4 sm:px-0 text-right text-gray-500 text-xs">
|
||||
{new Date(contact.createdAt).toLocaleDateString('fr-FR')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Création d'un compte employé ── */}
|
||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Créer un compte employé</h2>
|
||||
<p className="text-xs sm:text-sm text-gray-500 mb-4">
|
||||
Donne accès à l'espace employé (remise des lots en boutique). L'admin ne gère pas les remises lui-même.
|
||||
L'employé reçoit par email un mot de passe provisoire, qu'il devra changer à sa première connexion.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleCreateEmployee} className="grid sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label htmlFor="employee-firstname" className="block text-xs font-medium text-gray-700 mb-1">Prénom</label>
|
||||
<input
|
||||
type="text"
|
||||
id="employee-firstname"
|
||||
value={employeeForm.firstname}
|
||||
onChange={(e) => setEmployeeForm({ ...employeeForm, firstname: e.target.value })}
|
||||
required
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="employee-lastname" className="block text-xs font-medium text-gray-700 mb-1">Nom</label>
|
||||
<input
|
||||
type="text"
|
||||
id="employee-lastname"
|
||||
value={employeeForm.lastname}
|
||||
onChange={(e) => setEmployeeForm({ ...employeeForm, lastname: e.target.value })}
|
||||
required
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="employee-email" className="block text-xs font-medium text-gray-700 mb-1">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
id="employee-email"
|
||||
value={employeeForm.email}
|
||||
onChange={(e) => setEmployeeForm({ ...employeeForm, email: e.target.value })}
|
||||
required
|
||||
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2 flex items-center gap-3 pt-1">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={employeeSaving}
|
||||
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
|
||||
>
|
||||
{employeeSaving ? 'Création…' : 'Créer le compte'}
|
||||
</button>
|
||||
{employeeError && <p className="text-red-500 text-xs sm:text-sm">{employeeError}</p>}
|
||||
{employeeOk && <p className="text-green-600 text-xs sm:text-sm">{employeeOk}</p>}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* ── Employés et remises des lots ── */}
|
||||
<EmployeeDeliveries refreshKey={employeesVersion} onAuthError={handleAuthError} />
|
||||
|
||||
{/* ── Tirage au sort ── */}
|
||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Grand tirage au sort</h2>
|
||||
|
||||
{winnerUser ? (
|
||||
/* Gagnant déjà tiré */
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-4 p-4 bg-[#006B6B]/5 rounded-xl border border-[#006B6B]/20">
|
||||
<div className="w-14 h-14 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-lg font-bold flex-shrink-0">
|
||||
{(winnerUser.firstname?.[0] ?? '?').toUpperCase()}{(winnerUser.lastname?.[0] ?? '?').toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-[#006B6B] font-semibold uppercase tracking-wider mb-0.5">Gagnant du grand tirage</p>
|
||||
<p className="font-bold text-gray-900 text-base">{winnerUser.firstname} {winnerUser.lastname}</p>
|
||||
<p className="text-gray-500 text-sm truncate">{winnerUser.email}</p>
|
||||
</div>
|
||||
<div className="flex-shrink-0">
|
||||
<svg className="w-8 h-8 text-yellow-400" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{winner?.winner?.createdAt && (
|
||||
<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'email de notification n'a pas pu être envoyé au gagnant. Merci de le contacter directement à l'adresse ci-dessus.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Pas encore de tirage */
|
||||
<div className="space-y-3">
|
||||
<div className="p-4 bg-gray-50 rounded-xl border border-gray-200">
|
||||
<p className="text-sm text-gray-600 mb-1">
|
||||
<span className="font-semibold text-gray-800">{stats?.participations ?? 0}</span> participations enregistrées
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">
|
||||
<span className="font-semibold text-gray-800">{stats?.users ?? 0}</span> participants éligibles
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{drawError && (
|
||||
<p className="text-red-500 text-sm px-1">{drawError}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setShowConfirm(true)}
|
||||
disabled={drawing || (stats?.users ?? 0) === 0}
|
||||
className="flex items-center gap-2 bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-5 py-3 rounded-xl transition-colors text-sm"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||
</svg>
|
||||
{drawing ? 'Tirage en cours…' : 'Lancer le tirage au sort'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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-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]">
|
||||
<thead>
|
||||
<tr className="text-left text-gray-500 border-b text-xs sm:text-sm">
|
||||
<th className="pb-2 px-4 sm:px-0">Nom</th>
|
||||
<th className="pb-2 px-4 sm:px-0">Email</th>
|
||||
<th className="pb-2 px-4 sm:px-0 text-right">Participations</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={3} className="py-6 text-center text-gray-500 text-sm">
|
||||
Aucun participant pour le moment
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="border-b last:border-0">
|
||||
<td className="py-3 px-4 sm:px-0 font-medium text-gray-800 whitespace-nowrap text-xs sm:text-sm">
|
||||
{user.firstname} {user.lastname}
|
||||
</td>
|
||||
<td className="py-3 px-4 sm:px-0 text-gray-500 text-xs">{user.email}</td>
|
||||
<td className="py-3 px-4 sm:px-0 text-right text-[#006B6B] font-semibold text-xs sm:text-sm">
|
||||
{user._count.participations}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Modal de confirmation du tirage ── */}
|
||||
{showConfirm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 px-4">
|
||||
<div className="bg-white rounded-2xl w-full max-w-sm shadow-xl p-6">
|
||||
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-14 h-14 rounded-full bg-yellow-100 flex items-center justify-center">
|
||||
<svg className="w-7 h-7 text-yellow-500" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 2l2.4 7.4H22l-6.2 4.5 2.4 7.4L12 17l-6.2 4.3 2.4-7.4L2 9.4h7.6z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-bold text-gray-900 text-center mb-1">Lancer le tirage au sort ?</h3>
|
||||
<p className="text-sm text-gray-500 text-center mb-4">
|
||||
Un gagnant sera sélectionné aléatoirement parmi les <strong>{stats?.users}</strong> participants.
|
||||
</p>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl px-4 py-3 mb-5 flex gap-2 items-start">
|
||||
<svg className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
|
||||
</svg>
|
||||
<p className="text-xs text-red-600">Cette action est <strong>irréversible</strong>. Le tirage ne peut être effectué qu'une seule fois.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowConfirm(false)}
|
||||
className="flex-1 border border-gray-200 text-gray-600 hover:bg-gray-50 font-medium py-2.5 rounded-xl transition-colors text-sm"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDraw}
|
||||
className="flex-1 bg-[#006B6B] hover:bg-[#005959] text-white font-semibold py-2.5 rounded-xl transition-colors text-sm"
|
||||
>
|
||||
Confirmer le tirage
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
71
frontend/src/pages/AuthCallback.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import { homeFor } from '../config/roles'
|
||||
|
||||
// Point d'arrivée après un aller-retour OAuth : le backend nous renvoie ici
|
||||
// avec le JWT et le profil encodés dans l'URL.
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams] = useSearchParams()
|
||||
const { login } = useAuth()
|
||||
const handled = useRef(false)
|
||||
|
||||
const errorParam = searchParams.get('error')
|
||||
const token = searchParams.get('token')
|
||||
const encodedUser = searchParams.get('user')
|
||||
|
||||
let error = ''
|
||||
let user = null
|
||||
|
||||
if (errorParam) {
|
||||
error = errorParam
|
||||
} else if (!token || !encodedUser) {
|
||||
error = 'Réponse de connexion incomplète.'
|
||||
} else {
|
||||
try {
|
||||
// atob renvoie des octets : on les relit en UTF-8.
|
||||
const bytes = Uint8Array.from(
|
||||
atob(encodedUser),
|
||||
(c) => c.charCodeAt(0)
|
||||
)
|
||||
user = JSON.parse(new TextDecoder().decode(bytes))
|
||||
} catch {
|
||||
error = 'Réponse de connexion illisible.'
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (handled.current || !user) return
|
||||
|
||||
handled.current = true
|
||||
|
||||
login(user, token)
|
||||
navigate(homeFor(user), { replace: true })
|
||||
}, [user, token, 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>
|
||||
)
|
||||
}
|
||||
276
frontend/src/pages/Cgu.jsx
Normal file
@@ -0,0 +1,276 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import usePageMeta from '../hooks/usePageMeta'
|
||||
|
||||
export default function Cgu() {
|
||||
usePageMeta({
|
||||
title: 'Conditions générales d’utilisation | Thé Tip Top',
|
||||
description: "CGU du site Thé Tip Top : conditions d'accès au jeu-concours, inscription, participation et gestion de votre compte.",
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||
Conditions générales d'utilisation
|
||||
</h1>
|
||||
|
||||
<div className="mb-6 sm:mb-8 px-4 py-3 sm:px-5 sm:py-4 rounded-xl bg-[#006B6B]">
|
||||
<p className="text-white text-sm sm:text-base font-medium leading-relaxed">
|
||||
Projet étudiant fictif — Ce site a été conçu dans un cadre pédagogique. La société
|
||||
Thé Tip Top, l'agence Furious Ducks et l'ensemble des conditions présentées
|
||||
ci-dessous sont fictives. Aucun achat, paiement ou réservation réel ne peut être
|
||||
effectué sur ce site.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 mb-8 sm:mb-10 text-sm sm:text-base">
|
||||
Les présentes Conditions Générales d'Utilisation (CGU) précisent les modalités d'accès et
|
||||
d'utilisation du site internet du jeu-concours « 100% Gagnant » de la société Thé Tip Top.
|
||||
</p>
|
||||
|
||||
{[
|
||||
{
|
||||
title: 'Article 1 – Objet des CGU',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-2 text-sm sm:text-base leading-relaxed">
|
||||
<p>
|
||||
Les présentes Conditions Générales d'Utilisation (CGU) ont pour objet de définir
|
||||
les modalités et conditions dans lesquelles la société Thé Tip Top met à
|
||||
disposition des utilisateurs le site internet du jeu-concours « 100% Gagnant »,
|
||||
accessible à l'adresse https://www.dsp5-archi-o24a-4-5-g4.fr.
|
||||
</p>
|
||||
<p>
|
||||
Toute utilisation du Site implique l'acceptation pleine et entière des présentes
|
||||
CGU. En cas de non-acceptation, l'utilisateur est invité à ne pas accéder au Site
|
||||
ni à utiliser ses services.
|
||||
</p>
|
||||
<p>
|
||||
Le règlement complet du jeu-concours, déposé auprès de Maître Arnaud Rick,
|
||||
Commissaire de Justice, est{' '}
|
||||
<a
|
||||
href="/reglement-jeu-concours-100-gagnant.pdf"
|
||||
className="text-[#006B6B] hover:underline font-medium"
|
||||
>
|
||||
téléchargeable au format PDF
|
||||
</a>.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 2 – Conditions d\'accès au Site',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-2 text-sm sm:text-base leading-relaxed">
|
||||
<p><strong>Conditions d'âge —</strong> La participation au jeu-concours est strictement
|
||||
réservée aux personnes physiques majeures, c'est-à-dire âgées d'au moins 18 ans
|
||||
révolus à la date de leur inscription. Toute inscription d'un mineur est nulle et
|
||||
non avenue. La société Thé Tip Top se réserve le droit de demander la fourniture
|
||||
d'un justificatif d'identité pour vérifier l'âge déclaré.
|
||||
</p>
|
||||
<p><strong>Conditions géographiques —</strong> La participation au jeu-concours
|
||||
« 100% Gagnant » est réservée aux personnes majeures résidant en France
|
||||
métropolitaine. Seuls les tickets de caisse ou factures d'un montant supérieur à
|
||||
49 € émis dans la boutique Thé Tip Top de Nice, à l'occasion de son ouverture,
|
||||
donnent droit à un code de participation.
|
||||
</p>
|
||||
<p><strong>Personnes exclues —</strong> Sont exclus de toute participation les membres
|
||||
du personnel de la société Thé Tip Top et de l'agence Furious Ducks, leurs
|
||||
sous-traitants, ainsi que les membres de leur famille en ligne directe (conjoints,
|
||||
ascendants, descendants). Est également exclu le personnel de l'étude du
|
||||
Commissaire de Justice dépositaire du règlement.
|
||||
</p>
|
||||
<p><strong>Conditions techniques —</strong> L'accès au Site nécessite une connexion
|
||||
internet active et un navigateur web à jour. La société Thé Tip Top ne saurait
|
||||
être tenue responsable des difficultés d'accès liées à l'environnement technique
|
||||
de l'utilisateur.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 3 – Services proposés et fonctionnalités',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-4 text-sm sm:text-base leading-relaxed">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">3.1 – Services proposés</h3>
|
||||
<p>
|
||||
Le site du jeu-concours « 100% Gagnant » est un site internet dédié permettant
|
||||
aux clients de la société Thé Tip Top de participer à un jeu-concours réservé à
|
||||
la boutique de Nice, à l'occasion de son ouverture. Les services proposés
|
||||
incluent :
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 mt-2">
|
||||
<li>La présentation du jeu-concours, de ses conditions et des lots à gagner</li>
|
||||
<li>La création d'un espace personnel pour chaque participant</li>
|
||||
<li>La saisie et la validation des codes à 10 caractères figurant sur les tickets de caisse ou factures d'un montant supérieur à 49 € émis dans la boutique de Nice</li>
|
||||
<li>La consultation de l'historique des participations et gains obtenus</li>
|
||||
<li>La réclamation des lots gagnés dans la boutique de Nice</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">3.2 – Fonctionnalités principales</h3>
|
||||
<p><strong>S'inscrire —</strong> L'utilisateur peut créer un compte personnel en
|
||||
renseignant un formulaire d'inscription classique ou en utilisant les
|
||||
fonctionnalités d'authentification via Google. La création d'un compte est
|
||||
obligatoire pour participer au jeu-concours.
|
||||
</p>
|
||||
<p className="mt-2"><strong>Jouer (participer au jeu-concours) —</strong> Une fois
|
||||
connecté, l'utilisateur saisit le code à 10 caractères figurant sur son ticket de
|
||||
caisse ou sa facture. Le Site valide le code et attribue immédiatement le lot
|
||||
correspondant selon la répartition préétablie :
|
||||
</p>
|
||||
<ul className="space-y-2 mt-2">
|
||||
{[
|
||||
{ pct: '60 %', text: 'infuseur à thé' },
|
||||
{ pct: '20 %', text: "boîte de 100 g de thé détox ou d'infusion" },
|
||||
{ pct: '10 %', text: 'boîte de 100 g de thé signature' },
|
||||
{ pct: '6 %', text: "coffret découverte d'une valeur de 39 €" },
|
||||
{ pct: '4 %', text: "coffret découverte d'une valeur de 69 €" },
|
||||
].map((lot) => (
|
||||
<li key={lot.pct} className="flex gap-2">
|
||||
<span className="font-semibold text-[#006B6B] flex-shrink-0">{lot.pct}</span>
|
||||
<span className="min-w-0 flex-1">des tickets : {lot.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-2">
|
||||
Un même utilisateur peut soumettre plusieurs codes distincts. La participation
|
||||
est possible pendant toute la durée du jeu-concours, soit 30 jours à compter de
|
||||
son ouverture. Les lots gagnés peuvent être réclamés pendant cette période ainsi
|
||||
que dans les 30 jours calendaires suivant la date de clôture du jeu-concours.
|
||||
Passé ce délai, les lots non réclamés sont définitivement perdus.
|
||||
</p>
|
||||
<p className="mt-2"><strong>Remise des lots —</strong> Les lots peuvent être réclamés
|
||||
exclusivement dans la boutique Thé Tip Top de Nice, sur présentation du compte
|
||||
utilisateur validé et de la confirmation de gain. Aucun site marchand n'étant
|
||||
ouvert, aucune remise ni expédition à distance n'est possible.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
À l'issue du jeu-concours, un tirage au sort est effectué parmi l'ensemble des
|
||||
participants pour désigner le gagnant d'un an de thé d'une valeur de 360 €. Ce
|
||||
tirage a lieu sous le contrôle de Maître Arnaud Rick, Commissaire de Justice. Le
|
||||
nombre de participations n'augmente pas les chances de remporter ce lot.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 4 – Conditions d\'inscription et de création de compte',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-4 text-sm sm:text-base leading-relaxed">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">4.1 – Processus d'inscription</h3>
|
||||
<p>
|
||||
Pour accéder aux services du Site, l'utilisateur doit créer un compte personnel.
|
||||
L'inscription peut s'effectuer de deux manières :
|
||||
</p>
|
||||
<p className="mt-2"><strong>Via le formulaire classique —</strong> L'utilisateur
|
||||
renseigne les champs obligatoires suivants : nom, prénom, adresse e-mail valide,
|
||||
mot de passe sécurisé. L'utilisateur reçoit ensuite un e-mail de confirmation
|
||||
contenant un lien d'activation. Le compte n'est activé qu'après validation de ce
|
||||
lien.
|
||||
</p>
|
||||
<p className="mt-2"><strong>Via l'authentification Google —</strong> L'utilisateur
|
||||
peut s'inscrire en utilisant son compte Google. Dans ce cas, les données
|
||||
transmises par la plateforme tierce (nom, prénom, adresse e-mail) sont récupérées
|
||||
avec le consentement explicite de l'utilisateur.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">4.2 – Unicité du compte</h3>
|
||||
<p>
|
||||
Chaque utilisateur ne peut posséder qu'un seul compte. Toute tentative de
|
||||
création de comptes multiples par un même utilisateur est prohibée et peut
|
||||
entraîner la disqualification de l'ensemble des participations concernées.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">4.3 – Véracité des informations</h3>
|
||||
<p>
|
||||
L'utilisateur s'engage à fournir des informations exactes, complètes et à jour
|
||||
lors de son inscription et tout au long de l'utilisation du Site. Toute
|
||||
inscription effectuée avec une fausse identité ou des données inexactes sera
|
||||
annulée, ainsi que les participations et gains associés.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 5 – Responsabilité de l\'utilisateur',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-3 text-sm sm:text-base leading-relaxed">
|
||||
<p>
|
||||
L'utilisateur est seul responsable des informations communiquées lors de la
|
||||
création et de l'utilisation de son compte. À ce titre, il s'engage à :
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2">
|
||||
<li>Fournir des données personnelles exactes, sincères et à jour</li>
|
||||
<li>Ne pas usurper l'identité d'un tiers</li>
|
||||
<li>Ne pas créer de compte au nom d'une autre personne sans son autorisation</li>
|
||||
<li>Signaler sans délai à la société Thé Tip Top toute erreur, inexactitude ou modification de ses informations personnelles via les fonctionnalités de gestion du compte</li>
|
||||
</ul>
|
||||
<p>
|
||||
En cas de préjudice subi par la société Thé Tip Top ou par des tiers en raison de
|
||||
fausses informations fournies par l'utilisateur, ce dernier en assume l'entière
|
||||
responsabilité et peut se voir réclamer réparation.
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Article 6 – Gestion du compte utilisateur',
|
||||
content: (
|
||||
<div className="text-gray-700 space-y-4 text-sm sm:text-base leading-relaxed">
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">6.1 – Modification des informations</h3>
|
||||
<p>
|
||||
L'utilisateur peut à tout moment accéder à son espace personnel et modifier ses
|
||||
informations (nom, prénom, mot de passe).
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">6.2 – Suppression du compte</h3>
|
||||
<p>
|
||||
La suppression du compte n'est pas disponible en libre-service. L'utilisateur peut
|
||||
la demander à tout moment en contactant directement l'équipe via
|
||||
thetiptop.gp4@gmail.com, notamment depuis la rubrique dédiée de son{' '}
|
||||
<Link to="/profile#suppression-compte" className="text-[#006B6B] hover:underline font-medium">
|
||||
espace personnel
|
||||
</Link>
|
||||
. La suppression entraîne la perte définitive des participations et gains non
|
||||
encore réclamés. Les données personnelles sont supprimées conformément aux
|
||||
dispositions de la Politique de Confidentialité, sous réserve des obligations
|
||||
légales de conservation.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-800 mb-1">6.3 – Sécurité des identifiants</h3>
|
||||
<p>
|
||||
L'utilisateur est seul responsable de la confidentialité de ses identifiants de
|
||||
connexion (adresse e-mail et mot de passe). Il s'engage à ne les communiquer à
|
||||
personne et à prendre toutes les précautions nécessaires pour en assurer la
|
||||
protection. En cas de perte, de vol ou d'utilisation frauduleuse de ses
|
||||
identifiants, l'utilisateur doit en informer immédiatement la société Thé Tip
|
||||
Top. La société Thé Tip Top ne saurait être tenue responsable des dommages
|
||||
résultant de l'utilisation des identifiants par un tiers, sauf faute de sa part.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
].map((article) => (
|
||||
<section key={article.title} className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
{article.title}
|
||||
</h2>
|
||||
{article.content}
|
||||
</section>
|
||||
))}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
106
frontend/src/pages/Confidentialite.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import usePageMeta from '../hooks/usePageMeta'
|
||||
|
||||
export default function Confidentialite() {
|
||||
usePageMeta({
|
||||
title: 'Politique de confidentialité | Thé Tip Top',
|
||||
description: "Comment Thé Tip Top collecte et protège vos données personnelles dans le cadre du jeu-concours, et comment exercer vos droits RGPD.",
|
||||
})
|
||||
|
||||
return (
|
||||
<main className="bg-[#F0F0F0] min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
||||
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||
Politique de confidentialité
|
||||
</h1>
|
||||
|
||||
<p className="text-gray-600 mb-8 sm:mb-10 leading-relaxed text-sm sm:text-base">
|
||||
Thé Tip Top accorde une grande importance à la protection de vos données personnelles.
|
||||
Cette politique explique quelles informations sont collectées, comment elles sont utilisées
|
||||
et quels sont vos droits.
|
||||
</p>
|
||||
|
||||
<section className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
Article 1 – Responsable du traitement
|
||||
</h2>
|
||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||
Le responsable du traitement des données collectées via le site du jeu-concours
|
||||
« 100% Gagnant » est la société Thé Tip Top.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
Article 2 – Données collectées
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3 text-sm sm:text-base">Dans le cadre de votre participation, nous pouvons collecter :</p>
|
||||
<ul className="list-disc list-inside text-gray-700 space-y-2 text-sm sm:text-base">
|
||||
<li>Nom et prénom</li>
|
||||
<li>Adresse email</li>
|
||||
<li>Informations du compte utilisateur</li>
|
||||
<li>Code de participation au jeu</li>
|
||||
<li>Informations liées aux gains obtenus</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
Article 3 – Utilisation des données
|
||||
</h2>
|
||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||
Les données collectées sont utilisées uniquement pour : gérer votre participation au
|
||||
jeu-concours, vérifier les codes, attribuer les lots, vous contacter en cas de gain
|
||||
et améliorer l'expérience utilisateur.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
Article 4 – Conservation des données
|
||||
</h2>
|
||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||
Vos données sont conservées uniquement pendant la durée nécessaire au fonctionnement
|
||||
du jeu-concours et au respect des obligations légales de Thé Tip Top.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
Article 5 – Cookies
|
||||
</h2>
|
||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
||||
Le site utilise des cookies nécessaires au fonctionnement ainsi que des cookies
|
||||
statistiques permettant d'améliorer les services proposés. Vous pouvez gérer vos
|
||||
préférences à tout moment.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mb-6 sm:mb-8">
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
Article 6 – Vos droits
|
||||
</h2>
|
||||
<p className="text-gray-700 mb-3 text-sm sm:text-base">Conformément au RGPD, vous disposez des droits suivants :</p>
|
||||
<ul className="list-disc list-inside text-gray-700 space-y-2 text-sm sm:text-base">
|
||||
<li>Droit d'accès à vos données</li>
|
||||
<li>Droit de rectification</li>
|
||||
<li>Droit de suppression</li>
|
||||
<li>Droit d'opposition</li>
|
||||
<li>Droit à la limitation du traitement</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="text-lg sm:text-xl md:text-2xl font-bold text-[#006B6B] mb-2 sm:mb-3">
|
||||
Article 7 – Contact
|
||||
</h2>
|
||||
<p className="text-gray-700 text-sm sm:text-base">
|
||||
Pour toute question concernant vos données personnelles :<br />
|
||||
<strong>Email :</strong> thetiptop.gp4@gmail.com
|
||||
</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||