Compare commits
46 Commits
4d7ca7945b
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| f4786e970a | |||
| 361cf46905 | |||
| d56b91479f | |||
| 7c1e4cf3e2 | |||
| 3a5eed6b96 | |||
| e8080be405 | |||
| 82d3daf20f | |||
| 13584d2b0f | |||
| 9bf038278a | |||
| 10cffd77cb | |||
| 25fcd9974a | |||
| 536faaefd5 | |||
| 9edb3255e8 | |||
| b96c0b50ab | |||
| 8dce8b238c | |||
| abd14b713e | |||
| a7cafd1400 | |||
| 6e65c0392c | |||
| e257da41b0 | |||
| 89e49bc56d | |||
| a0869f6f65 | |||
| 4c2849611f | |||
| 279a071a15 | |||
| 1450c0b4c9 | |||
| 95a264dbae | |||
|
|
2201446ee6 | ||
|
|
8a6355cde1 | ||
|
|
28d743deb5 | ||
|
|
dfa5fb81a3 | ||
|
|
a1ab2df975 | ||
| 4c3e6cd384 | |||
| 55fb17634d | |||
|
|
fc9bcbf8da | ||
|
|
d29d2ea052 | ||
|
|
4f0b6d22f2 | ||
|
|
b1272bd008 | ||
|
|
9609a7fcf7 | ||
| 9cf88d9f5a | |||
| 87ad8988fa | |||
| 23c548992d | |||
| 7513bf45d6 | |||
| bdcdf1a4da | |||
| 6096da5bb8 | |||
| ad147eb930 | |||
|
|
61dcbda581 | ||
|
|
21648ab171 |
19
Jenkinsfile
vendored
@@ -83,12 +83,17 @@ pipeline {
|
|||||||
env.FRONTEND_API_URL = ''
|
env.FRONTEND_API_URL = ''
|
||||||
env.FRONTEND_GA_ID = ""
|
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 "Branche : ${env.BRANCH_NAME}"
|
||||||
echo "Tag Docker : ${env.IMAGE_TAG}"
|
echo "Tag Docker : ${env.IMAGE_TAG}"
|
||||||
echo "Environnement : ${env.DEPLOY_ENV}"
|
echo "Environnement : ${env.DEPLOY_ENV}"
|
||||||
echo "Déploiement activé : ${env.DEPLOY_ENABLED}"
|
echo "Déploiement activé : ${env.DEPLOY_ENABLED}"
|
||||||
echo "API frontend : ${env.FRONTEND_API_URL}"
|
echo "API frontend : ${env.FRONTEND_API_URL}"
|
||||||
|
echo "Turnstile : ${env.TURNSTILE_SITE_KEY ? '[SET]' : '[EMPTY]'}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -219,8 +224,8 @@ pipeline {
|
|||||||
docker run --rm \
|
docker run --rm \
|
||||||
--volumes-from furious-jenkins \
|
--volumes-from furious-jenkins \
|
||||||
-v tiptop-npm-cache-frontend:/npm-cache \
|
-v tiptop-npm-cache-frontend:/npm-cache \
|
||||||
--user "$(id -u):$(id -g)" \
|
|
||||||
-e HOME=/tmp \
|
-e HOME=/tmp \
|
||||||
|
-e CHROMIUM_PATH=/usr/bin/chromium \
|
||||||
-e NODE_OPTIONS=--dns-result-order=ipv4first \
|
-e NODE_OPTIONS=--dns-result-order=ipv4first \
|
||||||
-e NPM_CONFIG_CACHE=/npm-cache \
|
-e NPM_CONFIG_CACHE=/npm-cache \
|
||||||
-e NPM_CONFIG_FETCH_RETRIES=5 \
|
-e NPM_CONFIG_FETCH_RETRIES=5 \
|
||||||
@@ -230,6 +235,8 @@ pipeline {
|
|||||||
-w "${WORKSPACE}/frontend" \
|
-w "${WORKSPACE}/frontend" \
|
||||||
node:22-alpine \
|
node:22-alpine \
|
||||||
sh -c "
|
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 ci --prefer-offline --no-audit --no-fund &&
|
||||||
npm run lint &&
|
npm run lint &&
|
||||||
npm run test:coverage &&
|
npm run test:coverage &&
|
||||||
@@ -314,7 +321,7 @@ stage('SonarQube Quality Gate') {
|
|||||||
|
|
||||||
steps {
|
steps {
|
||||||
timeout(time: 5, unit: 'MINUTES') {
|
timeout(time: 5, unit: 'MINUTES') {
|
||||||
waitForQualityGate abortPipeline: false
|
waitForQualityGate abortPipeline: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,6 +334,13 @@ stage('SonarQube Quality Gate') {
|
|||||||
|
|
||||||
steps {
|
steps {
|
||||||
sh '''
|
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 \
|
docker build \
|
||||||
-t "${BACKEND_IMAGE}:${IMAGE_TAG}" \
|
-t "${BACKEND_IMAGE}:${IMAGE_TAG}" \
|
||||||
backend
|
backend
|
||||||
@@ -334,6 +348,7 @@ stage('SonarQube Quality Gate') {
|
|||||||
docker build \
|
docker build \
|
||||||
--build-arg VITE_API_URL="${FRONTEND_API_URL}" \
|
--build-arg VITE_API_URL="${FRONTEND_API_URL}" \
|
||||||
--build-arg VITE_GA_ID="${FRONTEND_GA_ID}" \
|
--build-arg VITE_GA_ID="${FRONTEND_GA_ID}" \
|
||||||
|
--build-arg VITE_TURNSTILE_SITE_KEY="${TURNSTILE_SITE_KEY:-}" \
|
||||||
-t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \
|
-t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \
|
||||||
frontend
|
frontend
|
||||||
'''
|
'''
|
||||||
|
|||||||
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.
|
||||||
@@ -19,3 +19,8 @@ GOOGLE_CLIENT_ID=""
|
|||||||
GOOGLE_CLIENT_SECRET=""
|
GOOGLE_CLIENT_SECRET=""
|
||||||
FACEBOOK_APP_ID=""
|
FACEBOOK_APP_ID=""
|
||||||
FACEBOOK_APP_SECRET=""
|
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=""
|
||||||
|
|||||||
12
backend/.gitignore
vendored
@@ -4,3 +4,15 @@ node_modules/
|
|||||||
coverage/
|
coverage/
|
||||||
dist/
|
dist/
|
||||||
test-results/
|
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
|
## 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 :
|
- inscription, vérification email et connexion ;
|
||||||
|
- authentification JWT et Google OAuth 2.0 ;
|
||||||
- l'application client (participants)
|
- rôles et autorisations ;
|
||||||
- l'espace employé en boutique
|
- tickets, participations et gains ;
|
||||||
- l'espace administrateur
|
- remise des lots ;
|
||||||
- les futurs services externes de l'écosystème Thé Tip Top
|
- 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
|
## Authentification
|
||||||
- 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**
|
|
||||||
|
|
||||||
### Jeu-concours
|
### Email / mot de passe
|
||||||
|
|
||||||
- Génération automatique des lots
|
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.
|
||||||
- Génération des tickets gagnants
|
|
||||||
- Participation via code ticket unique
|
|
||||||
- Attribution automatique des gains
|
|
||||||
- Historique des participations
|
|
||||||
- Historique des gains remportés
|
|
||||||
|
|
||||||
### Espace Employé
|
### Google OAuth 2.0
|
||||||
|
|
||||||
- Recherche d'un gain via un code ticket
|
```text
|
||||||
- Vérification du statut du gain
|
Utilisateur
|
||||||
- Validation de la remise d'un lot
|
↓
|
||||||
- Mise à jour automatique du statut du gain
|
Frontend
|
||||||
|
↓
|
||||||
### Espace Administrateur
|
API /api/auth/google
|
||||||
|
↓
|
||||||
- Tableau de bord statistique
|
Google OAuth 2.0
|
||||||
- Liste des participants
|
↓
|
||||||
- Liste des contacts ayant accepté les communications marketing
|
Callback backend
|
||||||
- Création de comptes employés
|
↓
|
||||||
- Tirage au sort du grand gagnant
|
Création / récupération utilisateur
|
||||||
- Consultation du gagnant du gros lot
|
↓
|
||||||
|
JWT
|
||||||
### Documentation API
|
↓
|
||||||
|
Redirection frontend
|
||||||
- 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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 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
|
```bash
|
||||||
npm install
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Variables d'environnement
|
Exemple de `.env` local :
|
||||||
|
|
||||||
Créer un fichier `.env` à la racine du projet :
|
```env
|
||||||
|
PORT=5000
|
||||||
|
NODE_ENV=development
|
||||||
```env **PORT**=**5000**```
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db"
|
||||||
|
JWT_SECRET="change_me"
|
||||||
```DATABASE_URL="postgresql://postgres:password@localhost:**5432**/the_tip_top_db*```
|
APP_URL="http://localhost:5000"
|
||||||
|
FRONTEND_URL="http://localhost:5173"
|
||||||
```JWT_SECRET=*your_secret_key" ```
|
MAIL_HOST="smtp.example.com"
|
||||||
|
MAIL_PORT=587
|
||||||
---
|
MAIL_USER="user@example.com"
|
||||||
|
MAIL_PASS="change_me"
|
||||||
## Base de données
|
MAIL_FROM="Thé Tip Top <user@example.com>"
|
||||||
|
GOOGLE_CLIENT_ID=""
|
||||||
### Générer les migrations
|
GOOGLE_CLIENT_SECRET=""
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma migrate dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Générer Prisma Client
|
Ces valeurs sont uniquement des exemples.
|
||||||
|
|
||||||
|
## Prisma
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx prisma generate
|
npx prisma generate
|
||||||
```
|
npx prisma migrate dev
|
||||||
|
npx prisma migrate deploy
|
||||||
### Exécuter le seed de la base
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma db seed
|
npx prisma db seed
|
||||||
```
|
|
||||||
|
|
||||||
### Générer les tickets du jeu-concours
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run generate:tickets
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ouvrir Prisma Studio
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma studio
|
npx prisma studio
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## Lancement
|
||||||
|
|
||||||
## Technologies utilisées
|
Développement :
|
||||||
|
|
||||||
| Technologie | Version |
|
|
||||||
| ----------- | ---------------- |
|
|
||||||
| Node.js | 20+ |
|
|
||||||
| Express.js | 5 |
|
|
||||||
| PostgreSQL | 16+ |
|
|
||||||
| Prisma ORM | 6 |
|
|
||||||
| JWT | Dernière version |
|
|
||||||
| Bcrypt | Dernière version |
|
|
||||||
| Zod | Dernière version |
|
|
||||||
| Swagger UI | Dernière version |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Lancement du projet
|
|
||||||
|
|
||||||
### Développement
|
|
||||||
|
|
||||||
Le projet utilise **Nodemon** afin de redémarrer automatiquement le serveur lors des modifications du code.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
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
|
## 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) ```
|
```text
|
||||||
|
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## CI/CD
|
||||||
|
|
||||||
## Règles métier
|
Le projet utilise Gitea, Jenkins, Docker et un Registry privé.
|
||||||
|
|
||||||
- Tous les tickets sont gagnants.
|
```text
|
||||||
- Un ticket ne peut être utilisé qu'une seule fois.
|
feature/* → CI uniquement
|
||||||
- Les gains sont associés aux tickets dès leur génération.
|
develop → DEV automatique
|
||||||
- Plusieurs participations n'augmentent pas les chances de remporter le gros lot.
|
release-* → PREPROD automatique
|
||||||
- Un seul gagnant du gros lot peut être enregistré.
|
main → validation manuelle → PROD
|
||||||
- Les gains non récupérés dans les délais peuvent être annulés.
|
```
|
||||||
|
|
||||||
---
|
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**.
|
Client fictif : **Thé Tip Top**.
|
||||||
@@ -3,6 +3,8 @@ module.exports = {
|
|||||||
|
|
||||||
testMatch: ["**/tests/**/*.test.js"],
|
testMatch: ["**/tests/**/*.test.js"],
|
||||||
|
|
||||||
|
setupFiles: ["<rootDir>/tests/setupEnv.js"],
|
||||||
|
|
||||||
clearMocks: true,
|
clearMocks: true,
|
||||||
|
|
||||||
testTimeout: 10000,
|
testTimeout: 10000,
|
||||||
|
|||||||
12
backend/package-lock.json
generated
@@ -18,6 +18,7 @@
|
|||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
|
"passport-facebook": "^3.0.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"swagger-jsdoc": "^6.3.0",
|
"swagger-jsdoc": "^6.3.0",
|
||||||
@@ -5845,6 +5846,17 @@
|
|||||||
"url": "https://github.com/sponsors/jaredhanson"
|
"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": {
|
"node_modules/passport-google-oauth20": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
|
"passport-facebook": "^3.0.0",
|
||||||
"passport-google-oauth20": "^2.0.0",
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"swagger-jsdoc": "^6.3.0",
|
"swagger-jsdoc": "^6.3.0",
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
);
|
||||||
@@ -51,6 +51,11 @@ model User {
|
|||||||
resetPasswordToken String?
|
resetPasswordToken String?
|
||||||
resetPasswordExpires DateTime?
|
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)
|
provider AuthProvider @default(LOCAL)
|
||||||
providerId String?
|
providerId String?
|
||||||
avatarUrl String?
|
avatarUrl String?
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const prisma = require("../src/config/prisma");
|
const prisma = require("../src/config/prisma");
|
||||||
|
const { DEFAULT_START_DATE } = require("../src/utils/gameWindow");
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
await prisma.lot.deleteMany();
|
await prisma.lot.deleteMany();
|
||||||
@@ -42,8 +43,8 @@ async function main() {
|
|||||||
|
|
||||||
const existingSettings = await prisma.gameSettings.findFirst();
|
const existingSettings = await prisma.gameSettings.findFirst();
|
||||||
if (!existingSettings) {
|
if (!existingSettings) {
|
||||||
await prisma.gameSettings.create({ data: {} });
|
await prisma.gameSettings.create({ data: { startDate: DEFAULT_START_DATE } });
|
||||||
console.log("Date de début du jeu-concours initialisée à aujourd'hui.");
|
console.log("Date de début du jeu-concours initialisée au 2 octobre 2026.");
|
||||||
} else {
|
} else {
|
||||||
console.log("Date de début du jeu-concours déjà définie, inchangée.");
|
console.log("Date de début du jeu-concours déjà définie, inchangée.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,22 @@
|
|||||||
const passport = require("passport");
|
const passport = require("passport");
|
||||||
const GoogleStrategy = require("passport-google-oauth20").Strategy;
|
const GoogleStrategy = require("passport-google-oauth20").Strategy;
|
||||||
|
const FacebookStrategy = require("passport-facebook").Strategy;
|
||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const prisma = require("./prisma");
|
const prisma = require("./prisma");
|
||||||
const transporter = require("./mailer");
|
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
|
// 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
|
// propose un dès l'inscription, via le même mécanisme de jeton que la
|
||||||
// réinitialisation : sans mot de passe, l'utilisateur dépend entièrement de
|
// réinitialisation : sans mot de passe, l'utilisateur dépend entièrement du
|
||||||
// Google pour se connecter, et le formulaire de changement de son profil lui
|
// provider pour se connecter, et le formulaire de changement de son profil lui
|
||||||
// reste inaccessible.
|
// reste inaccessible.
|
||||||
const PASSWORD_SETUP_EXPIRY_MS = 1000 * 60 * 60 * 24; // 24 h
|
const PASSWORD_SETUP_EXPIRY_MS = 1000 * 60 * 60 * 24; // 24 h
|
||||||
|
|
||||||
async function sendPasswordSetupEmail(user) {
|
async function sendPasswordSetupEmail(user, providerLabel) {
|
||||||
const token = crypto.randomBytes(32).toString("hex");
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
@@ -22,8 +27,7 @@ async function sendPasswordSetupEmail(user) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
const setupUrl = `${getFrontendUrl()}/definir-mot-de-passe/${token}`;
|
||||||
const setupUrl = `${frontendUrl}/definir-mot-de-passe/${token}`;
|
|
||||||
|
|
||||||
await transporter.sendMail({
|
await transporter.sendMail({
|
||||||
from: process.env.MAIL_FROM,
|
from: process.env.MAIL_FROM,
|
||||||
@@ -32,9 +36,9 @@ async function sendPasswordSetupEmail(user) {
|
|||||||
html: `
|
html: `
|
||||||
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||||
<h2 style="color:#006B6B">Bienvenue ${user.firstname} !</h2>
|
<h2 style="color:#006B6B">Bienvenue ${user.firstname} !</h2>
|
||||||
<p>Votre compte a été créé avec Google. Vous pouvez continuer à vous
|
<p>Votre compte a été créé avec ${providerLabel}. Vous pouvez continuer à vous
|
||||||
connecter de cette façon, ou définir un mot de passe pour accéder
|
connecter de cette façon, ou définir un mot de passe pour accéder
|
||||||
également à votre compte sans passer par Google.</p>
|
également à votre compte sans passer par ${providerLabel}.</p>
|
||||||
<p style="text-align:center;padding:20px 0">
|
<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">
|
<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
|
Définir mon mot de passe
|
||||||
@@ -42,48 +46,39 @@ async function sendPasswordSetupEmail(user) {
|
|||||||
</p>
|
</p>
|
||||||
<p style="color:#888;font-size:13px">Ce lien expire dans 24 heures. Si vous
|
<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
|
ne souhaitez pas de mot de passe, ignorez simplement cet email : votre
|
||||||
connexion Google continuera de fonctionner.</p>
|
connexion ${providerLabel} continuera de fonctionner.</p>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authentification stateless : le callback OAuth émet directement notre JWT,
|
// Callback de vérification commun à tous les providers : passport normalise
|
||||||
// donc pas de session Express ni de serialize/deserialize à brancher.
|
// le profil (id, name, emails, photos), seule la valeur de `provider` change.
|
||||||
|
function verifyOAuthProfile(provider) {
|
||||||
|
const providerLabel = PROVIDER_LABELS[provider];
|
||||||
|
|
||||||
const googleEnabled =
|
return async (accessToken, refreshToken, profile, done) => {
|
||||||
Boolean(process.env.GOOGLE_CLIENT_ID) && Boolean(process.env.GOOGLE_CLIENT_SECRET);
|
|
||||||
|
|
||||||
if (googleEnabled) {
|
|
||||||
passport.use(
|
|
||||||
new GoogleStrategy(
|
|
||||||
{
|
|
||||||
clientID: process.env.GOOGLE_CLIENT_ID,
|
|
||||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
|
|
||||||
callbackURL: `${process.env.APP_URL || "http://localhost:5000"}/api/auth/google/callback`,
|
|
||||||
scope: ["profile", "email"],
|
|
||||||
},
|
|
||||||
async (accessToken, refreshToken, profile, done) => {
|
|
||||||
try {
|
try {
|
||||||
const email = profile.emails?.[0]?.value;
|
const email = normalizeEmail(profile.emails?.[0]?.value);
|
||||||
|
|
||||||
|
// Cas fréquent chez Facebook : compte ouvert avec un numéro de téléphone.
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return done(null, false, {
|
return done(null, false, {
|
||||||
message: "Aucune adresse email n'est associée à ce compte Google.",
|
message: `Aucune adresse email n'est associée à ce compte ${providerLabel}.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Compte déjà lié à ce compte Google : connexion directe.
|
// 1. Compte déjà lié à ce compte externe : connexion directe.
|
||||||
const byProvider = await prisma.user.findUnique({
|
const byProvider = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
provider_providerId: { provider: "GOOGLE", providerId: profile.id },
|
provider_providerId: { provider, providerId: profile.id },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (byProvider) return done(null, byProvider);
|
if (byProvider) return done(null, byProvider);
|
||||||
|
|
||||||
// 2. Un compte existe avec le même email : on le rattache à Google.
|
// 2. Un compte existe avec le même email : on le rattache au provider.
|
||||||
// L'email étant déjà validé par Google, on en profite pour lever
|
// L'email étant déjà validé par le provider, on en profite pour lever
|
||||||
// la vérification si elle était en attente.
|
// la vérification si elle était en attente.
|
||||||
const byEmail = await prisma.user.findUnique({ where: { email } });
|
const byEmail = await prisma.user.findUnique({ where: { email } });
|
||||||
|
|
||||||
@@ -91,7 +86,7 @@ if (googleEnabled) {
|
|||||||
const linked = await prisma.user.update({
|
const linked = await prisma.user.update({
|
||||||
where: { id: byEmail.id },
|
where: { id: byEmail.id },
|
||||||
data: {
|
data: {
|
||||||
provider: "GOOGLE",
|
provider,
|
||||||
providerId: profile.id,
|
providerId: profile.id,
|
||||||
isEmailVerified: true,
|
isEmailVerified: true,
|
||||||
emailVerifyToken: null,
|
emailVerifyToken: null,
|
||||||
@@ -109,7 +104,7 @@ if (googleEnabled) {
|
|||||||
lastname: profile.name?.familyName || "",
|
lastname: profile.name?.familyName || "",
|
||||||
email,
|
email,
|
||||||
password: null,
|
password: null,
|
||||||
provider: "GOOGLE",
|
provider,
|
||||||
providerId: profile.id,
|
providerId: profile.id,
|
||||||
avatarUrl: profile.photos?.[0]?.value || null,
|
avatarUrl: profile.photos?.[0]?.value || null,
|
||||||
isEmailVerified: true,
|
isEmailVerified: true,
|
||||||
@@ -119,7 +114,7 @@ if (googleEnabled) {
|
|||||||
// Un envoi qui échoue ne doit pas bloquer la connexion : le compte
|
// Un envoi qui échoue ne doit pas bloquer la connexion : le compte
|
||||||
// est valide, et « mot de passe oublié » permet de rattraper.
|
// est valide, et « mot de passe oublié » permet de rattraper.
|
||||||
try {
|
try {
|
||||||
await sendPasswordSetupEmail(created);
|
await sendPasswordSetupEmail(created, providerLabel);
|
||||||
} catch (mailError) {
|
} catch (mailError) {
|
||||||
console.error("Erreur envoi email de definition du mot de passe:", mailError);
|
console.error("Erreur envoi email de definition du mot de passe:", mailError);
|
||||||
}
|
}
|
||||||
@@ -128,9 +123,49 @@ if (googleEnabled) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return done(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")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { passport, googleEnabled };
|
if (facebookEnabled) {
|
||||||
|
passport.use(
|
||||||
|
new FacebookStrategy(
|
||||||
|
{
|
||||||
|
clientID: process.env.FACEBOOK_APP_ID,
|
||||||
|
clientSecret: process.env.FACEBOOK_APP_SECRET,
|
||||||
|
callbackURL: `${apiUrl}/api/auth/facebook/callback`,
|
||||||
|
// Sans profileFields, la Graph API ne renvoie que l'id et le nom.
|
||||||
|
profileFields: ["id", "displayName", "name", "emails", "photos"],
|
||||||
|
// appsecret_proof : empêche l'usage d'un access token volé ailleurs.
|
||||||
|
enableProof: true,
|
||||||
|
},
|
||||||
|
verifyOAuthProfile("FACEBOOK")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { passport, googleEnabled, facebookEnabled, verifyOAuthProfile };
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ const bcrypt = require("bcrypt");
|
|||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const prisma = require("../config/prisma");
|
const prisma = require("../config/prisma");
|
||||||
const transporter = require("../config/mailer");
|
const transporter = require("../config/mailer");
|
||||||
const { getGameWindow } = require("../utils/gameWindow");
|
const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow");
|
||||||
|
const getFrontendUrl = require("../utils/frontendUrl");
|
||||||
|
|
||||||
const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"];
|
const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"];
|
||||||
|
|
||||||
@@ -166,9 +167,30 @@ const getEmailingContacts = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Sans caractères ambigus (0/O, 1/l/I) : l'employé peut devoir le recopier.
|
||||||
|
const TEMP_PASSWORD_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
||||||
|
// Aucun caractère à échapper dans l'email HTML (&, <, >, guillemets).
|
||||||
|
const TEMP_PASSWORD_SPECIALS = "!@#$%*?";
|
||||||
|
const TEMP_PASSWORD_LENGTH = 12;
|
||||||
|
|
||||||
|
// Respecte la règle commune des mots de passe (au moins un caractère spécial),
|
||||||
|
// placé à une position aléatoire.
|
||||||
|
function generateTempPassword() {
|
||||||
|
const chars = [];
|
||||||
|
for (let i = 0; i < TEMP_PASSWORD_LENGTH - 1; i++) {
|
||||||
|
chars.push(TEMP_PASSWORD_ALPHABET[crypto.randomInt(TEMP_PASSWORD_ALPHABET.length)]);
|
||||||
|
}
|
||||||
|
const special = TEMP_PASSWORD_SPECIALS[crypto.randomInt(TEMP_PASSWORD_SPECIALS.length)];
|
||||||
|
chars.splice(crypto.randomInt(chars.length + 1), 0, special);
|
||||||
|
return chars.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'admin ne choisit pas le mot de passe de l'employé : on en génère un
|
||||||
|
// provisoire, envoyé uniquement par email, que l'employé devra remplacer à
|
||||||
|
// sa première connexion (mustChangePassword).
|
||||||
const createEmployee = async (req, res) => {
|
const createEmployee = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { firstname, lastname, email, password, birthdate, gender } = req.body;
|
const { firstname, lastname, email, birthdate, gender } = req.body;
|
||||||
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
const existingUser = await prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
@@ -180,7 +202,8 @@ const createEmployee = async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const tempPassword = generateTempPassword();
|
||||||
|
const hashedPassword = await bcrypt.hash(tempPassword, 10);
|
||||||
|
|
||||||
const employee = await prisma.user.create({
|
const employee = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -192,6 +215,8 @@ const createEmployee = async (req, res) => {
|
|||||||
gender,
|
gender,
|
||||||
role: "EMPLOYEE",
|
role: "EMPLOYEE",
|
||||||
marketingOptIn: false,
|
marketingOptIn: false,
|
||||||
|
isEmailVerified: true,
|
||||||
|
mustChangePassword: true,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -203,8 +228,45 @@ const createEmployee = async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const loginUrl = `${getFrontendUrl()}/connexion`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.MAIL_FROM,
|
||||||
|
to: email,
|
||||||
|
subject: "Votre compte employé Thé Tip Top",
|
||||||
|
html: `
|
||||||
|
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||||
|
<h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
|
||||||
|
<p>Un compte employé Thé Tip Top vient d'être créé pour vous.</p>
|
||||||
|
<p>Voici vos identifiants de première connexion :</p>
|
||||||
|
<p style="background:#f3f4f6;padding:12px 16px;border-radius:8px">
|
||||||
|
Email : <strong>${email}</strong><br />
|
||||||
|
Mot de passe provisoire : <strong style="font-family:monospace;font-size:16px">${tempPassword}</strong>
|
||||||
|
</p>
|
||||||
|
<p style="text-align:center;padding:20px 0">
|
||||||
|
<a href="${loginUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
|
||||||
|
Me connecter
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p style="color:#888;font-size:13px">Dès votre première connexion, vous devrez
|
||||||
|
choisir un nouveau mot de passe. Le mot de passe provisoire ne fonctionnera
|
||||||
|
plus ensuite.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (mailError) {
|
||||||
|
// Sans l'email, l'employé n'a aucun moyen de connaître son mot de passe :
|
||||||
|
// on annule la création pour que l'admin puisse simplement réessayer.
|
||||||
|
console.error("Erreur envoi email employé:", mailError);
|
||||||
|
await prisma.user.delete({ where: { id: employee.id } });
|
||||||
|
return res.status(502).json({
|
||||||
|
message: "L'email d'invitation n'a pas pu être envoyé : le compte n'a pas été créé. Réessayez.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(201).json({
|
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,
|
employee,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -427,11 +489,137 @@ const getGrandPrizeWinner = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const PERSON_SELECT = { id: true, firstname: true, lastname: true, email: true };
|
||||||
|
|
||||||
|
// Comptes employés, avec le nombre de lots que chacun a remis. `q` filtre sur
|
||||||
|
// le prénom, le nom ou l'email.
|
||||||
|
const getEmployees = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
|
||||||
|
const contains = { contains: q, mode: "insensitive" };
|
||||||
|
|
||||||
|
const employees = await prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
role: "EMPLOYEE",
|
||||||
|
...(q && { OR: [{ firstname: contains }, { lastname: contains }, { email: contains }] }),
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
...PERSON_SELECT,
|
||||||
|
createdAt: true,
|
||||||
|
mustChangePassword: true,
|
||||||
|
_count: { select: { claimsValidated: { where: { status: "DELIVERED" } } } },
|
||||||
|
},
|
||||||
|
orderBy: [{ lastname: "asc" }, { firstname: "asc" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
employees: employees.map(({ _count, ...employee }) => ({
|
||||||
|
...employee,
|
||||||
|
deliveredCount: _count.claimsValidated,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur liste employés:", error);
|
||||||
|
return res.status(500).json({ message: "Erreur serveur lors de la récupération des employés." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Lots remis par un employé, du plus récent au plus ancien.
|
||||||
|
const getEmployeeDeliveries = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = Number(req.params.id);
|
||||||
|
if (!Number.isInteger(id)) {
|
||||||
|
return res.status(400).json({ message: "Identifiant d'employé invalide." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const employee = await prisma.user.findFirst({
|
||||||
|
where: { id, role: "EMPLOYEE" },
|
||||||
|
select: PERSON_SELECT,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!employee) {
|
||||||
|
return res.status(404).json({ message: "Employé introuvable." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = await prisma.claim.findMany({
|
||||||
|
where: { validatedByEmployeeId: id, status: "DELIVERED" },
|
||||||
|
orderBy: { claimedAt: "desc" },
|
||||||
|
include: {
|
||||||
|
participation: {
|
||||||
|
include: {
|
||||||
|
ticket: { select: { code: true } },
|
||||||
|
lot: { select: { name: true, value: true } },
|
||||||
|
user: { select: PERSON_SELECT },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
employee,
|
||||||
|
deliveries: claims.map((claim) => ({
|
||||||
|
claimId: claim.id,
|
||||||
|
ticketCode: claim.participation.ticket.code,
|
||||||
|
lot: claim.participation.lot,
|
||||||
|
client: claim.participation.user,
|
||||||
|
deliveredAt: claim.claimedAt,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur remises employé:", error);
|
||||||
|
return res.status(500).json({ message: "Erreur serveur lors de la récupération des remises." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Traçabilité d'un ticket : qui l'a joué, quel lot, et quel employé l'a remis
|
||||||
|
// et quand. `status` vaut NOT_PLAYED tant qu'aucun client ne l'a saisi.
|
||||||
|
const getDeliveryByTicketCode = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const code = req.params.code.trim().toUpperCase();
|
||||||
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
|
const ticket = await prisma.ticket.findUnique({
|
||||||
|
where: { code },
|
||||||
|
include: {
|
||||||
|
lot: { select: { name: true, value: true } },
|
||||||
|
participation: {
|
||||||
|
include: {
|
||||||
|
user: { select: PERSON_SELECT },
|
||||||
|
claim: { include: { validatedByEmployee: { select: PERSON_SELECT } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ticket) {
|
||||||
|
return res.status(404).json({ message: "Aucun ticket ne correspond à ce numéro." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const claim = ticket.participation?.claim;
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
ticketCode: ticket.code,
|
||||||
|
lot: ticket.lot,
|
||||||
|
status: claim ? effectiveClaimStatus(claim.status, gameWindow) : "NOT_PLAYED",
|
||||||
|
playedAt: ticket.usedAt,
|
||||||
|
client: ticket.participation?.user ?? null,
|
||||||
|
deliveredBy: claim?.validatedByEmployee ?? null,
|
||||||
|
deliveredAt: claim?.status === "DELIVERED" ? claim.claimedAt : null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur recherche remise:", error);
|
||||||
|
return res.status(500).json({ message: "Erreur serveur lors de la recherche du ticket." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getStats,
|
getStats,
|
||||||
getUsers,
|
getUsers,
|
||||||
getEmailingContacts,
|
getEmailingContacts,
|
||||||
createEmployee,
|
createEmployee,
|
||||||
|
getEmployees,
|
||||||
|
getEmployeeDeliveries,
|
||||||
|
getDeliveryByTicketCode,
|
||||||
drawWinner,
|
drawWinner,
|
||||||
getGrandPrizeWinner,
|
getGrandPrizeWinner,
|
||||||
setGameSettings,
|
setGameSettings,
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ const jwt = require("jsonwebtoken");
|
|||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const prisma = require("../config/prisma");
|
const prisma = require("../config/prisma");
|
||||||
const transporter = require("../config/mailer");
|
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) => {
|
const register = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -210,6 +213,7 @@ const login = async (req, res) => {
|
|||||||
lastname: user.lastname,
|
lastname: user.lastname,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
mustChangePassword: user.mustChangePassword,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -220,9 +224,47 @@ const login = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const RESET_LINK_EXPIRY_MS = 1000 * 60 * 30; // 30 min
|
||||||
|
|
||||||
|
// Enregistre un jeton de réinitialisation (qui remplace le précédent) et envoie
|
||||||
|
// le lien par email. Partagé par « mot de passe oublié » et par le profil.
|
||||||
|
async function sendPasswordResetEmail(user) {
|
||||||
|
const resetPasswordToken = crypto.randomBytes(32).toString("hex");
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
resetPasswordToken,
|
||||||
|
resetPasswordExpires: new Date(Date.now() + RESET_LINK_EXPIRY_MS),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Le lien doit ouvrir le formulaire du front, pas la route POST de l'API :
|
||||||
|
// un clic depuis un email est une requête GET, qui tombait en 404.
|
||||||
|
const resetUrl = `${getFrontendUrl()}/definir-mot-de-passe/${resetPasswordToken}`;
|
||||||
|
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.MAIL_FROM,
|
||||||
|
to: user.email,
|
||||||
|
subject: "Réinitialisation de votre mot de passe Thé Tip Top",
|
||||||
|
html: `
|
||||||
|
<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) => {
|
const forgotPassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email } = req.body;
|
const email = normalizeEmail(req.body.email);
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
@@ -236,45 +278,12 @@ const forgotPassword = async (req, res) => {
|
|||||||
|
|
||||||
// Sécurité : ne pas dire si l'email existe ou non
|
// Sécurité : ne pas dire si l'email existe ou non
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(200).json({
|
return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE });
|
||||||
message: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetPasswordToken = crypto.randomBytes(32).toString("hex");
|
await sendPasswordResetEmail(user);
|
||||||
const resetPasswordExpires = new Date(Date.now() + 1000 * 60 * 30); // 30 min
|
|
||||||
|
|
||||||
await prisma.user.update({
|
return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE });
|
||||||
where: { id: user.id },
|
|
||||||
data: {
|
|
||||||
resetPasswordToken,
|
|
||||||
resetPasswordExpires,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Le lien doit ouvrir le formulaire du front, pas la route POST de l'API :
|
|
||||||
// un clic depuis un email est une requête GET, qui tombait en 404.
|
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
|
||||||
const resetUrl = `${frontendUrl}/definir-mot-de-passe/${resetPasswordToken}`;
|
|
||||||
|
|
||||||
await transporter.sendMail({
|
|
||||||
from: process.env.MAIL_FROM,
|
|
||||||
to: user.email,
|
|
||||||
subject: "Réinitialisation de votre mot de passe Thé Tip Top",
|
|
||||||
html: `
|
|
||||||
<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é.",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur forgotPassword:", error);
|
console.error("Erreur forgotPassword:", error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
@@ -283,15 +292,27 @@ const forgotPassword = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Profil connecté (compte Google sans mot de passe) : même lien, envoyé à
|
||||||
|
// l'adresse du compte. Authentifié, donc sans captcha.
|
||||||
|
const sendPasswordSetupLink = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||||
|
await sendPasswordResetEmail(user);
|
||||||
|
return res.json({ message: `Lien envoyé à ${user.email}.` });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur sendPasswordSetupLink:", error);
|
||||||
|
return res.status(500).json({ message: "Erreur serveur lors de l'envoi du lien." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const resetPassword = async (req, res) => {
|
const resetPassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { token } = req.params;
|
const { token } = req.params;
|
||||||
const { password, confirmPassword } = req.body;
|
const { password, confirmPassword } = req.body;
|
||||||
|
|
||||||
if (!password || password.length < 8) {
|
const passwordError = passwordRuleError(password);
|
||||||
return res.status(400).json({
|
if (passwordError) {
|
||||||
message: "Le nouveau mot de passe doit contenir au moins 8 caractères.",
|
return res.status(400).json({ message: passwordError });
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// La confirmation est vérifiée côté serveur aussi : le contrôle du
|
// La confirmation est vérifiée côté serveur aussi : le contrôle du
|
||||||
@@ -325,6 +346,14 @@ const resetPassword = async (req, res) => {
|
|||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
resetPasswordToken: null,
|
resetPasswordToken: null,
|
||||||
resetPasswordExpires: null,
|
resetPasswordExpires: null,
|
||||||
|
// Le lien reçu par email prouve l'accès à la boîte : le mot de passe
|
||||||
|
// choisi ici remplace aussi un éventuel mot de passe provisoire, et
|
||||||
|
// l'adresse est considérée comme validée (sinon la connexion qui suit
|
||||||
|
// serait refusée pour email non vérifié).
|
||||||
|
mustChangePassword: false,
|
||||||
|
isEmailVerified: true,
|
||||||
|
emailVerifyToken: null,
|
||||||
|
emailVerifyExpires: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -341,7 +370,8 @@ const resetPassword = async (req, res) => {
|
|||||||
|
|
||||||
const verifyCode = async (req, res) => {
|
const verifyCode = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { email, code } = req.body;
|
const { code } = req.body;
|
||||||
|
const email = normalizeEmail(req.body.email);
|
||||||
|
|
||||||
if (!email || !code) {
|
if (!email || !code) {
|
||||||
return res.status(400).json({ message: "Email et code requis." });
|
return res.status(400).json({ message: "Email et code requis." });
|
||||||
@@ -397,7 +427,7 @@ const verifyCode = async (req, res) => {
|
|||||||
// le même JWT que la connexion classique et on renvoie le navigateur vers le
|
// 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.
|
// front, qui récupère le token dans l'URL.
|
||||||
const oauthCallback = (req, res) => {
|
const oauthCallback = (req, res) => {
|
||||||
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
|
const frontendUrl = getFrontendUrl();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = req.user;
|
const user = req.user;
|
||||||
@@ -423,10 +453,15 @@ const oauthCallback = (req, res) => {
|
|||||||
lastname: user.lastname,
|
lastname: user.lastname,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
|
mustChangePassword: user.mustChangePassword,
|
||||||
})
|
})
|
||||||
).toString("base64");
|
).toString("base64");
|
||||||
|
|
||||||
return res.redirect(`${frontendUrl}/auth/callback?token=${token}&user=${payload}`);
|
// encodeURIComponent : le base64 peut contenir des « + » que le front
|
||||||
|
// relirait comme des espaces dans la query string.
|
||||||
|
return res.redirect(
|
||||||
|
`${frontendUrl}/auth/callback?token=${token}&user=${encodeURIComponent(payload)}`
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur oauthCallback:", error);
|
console.error("Erreur oauthCallback:", error);
|
||||||
return res.redirect(
|
return res.redirect(
|
||||||
@@ -486,6 +521,47 @@ const deleteAccount = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Première connexion d'un employé : remplace le mot de passe provisoire reçu
|
||||||
|
// par email. L'utilisateur vient de s'authentifier avec ce mot de passe, on ne
|
||||||
|
// le redemande donc pas.
|
||||||
|
const setInitialPassword = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { password, confirmPassword } = req.body;
|
||||||
|
|
||||||
|
if (!req.user.mustChangePassword) {
|
||||||
|
return res.status(400).json({ message: "Votre mot de passe a déjà été défini." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordError = passwordRuleError(password);
|
||||||
|
if (passwordError) {
|
||||||
|
return res.status(400).json({ message: passwordError });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
return res.status(400).json({ message: "Les deux mots de passe ne correspondent pas." });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
|
||||||
|
|
||||||
|
if (user.password && (await bcrypt.compare(password, user.password))) {
|
||||||
|
return res.status(400).json({
|
||||||
|
message: "Choisissez un mot de passe différent du mot de passe provisoire.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashed = await bcrypt.hash(password, 10);
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: { password: hashed, mustChangePassword: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ message: "Mot de passe enregistré." });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur setInitialPassword:", error);
|
||||||
|
return res.status(500).json({ message: "Erreur serveur lors de l'enregistrement du mot de passe." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const changePassword = async (req, res) => {
|
const changePassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { currentPassword, newPassword } = req.body;
|
const { currentPassword, newPassword } = req.body;
|
||||||
@@ -495,8 +571,9 @@ const changePassword = async (req, res) => {
|
|||||||
return res.status(400).json({ message: "Les deux mots de passe sont requis." });
|
return res.status(400).json({ message: "Les deux mots de passe sont requis." });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newPassword.length < 8) {
|
const passwordError = passwordRuleError(newPassword);
|
||||||
return res.status(400).json({ message: "Le nouveau mot de passe doit faire au moins 8 caractères." });
|
if (passwordError) {
|
||||||
|
return res.status(400).json({ message: passwordError });
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
@@ -531,7 +608,9 @@ module.exports = {
|
|||||||
verifyCode,
|
verifyCode,
|
||||||
resetPassword,
|
resetPassword,
|
||||||
forgotPassword,
|
forgotPassword,
|
||||||
|
sendPasswordSetupLink,
|
||||||
changePassword,
|
changePassword,
|
||||||
|
setInitialPassword,
|
||||||
deleteAccount,
|
deleteAccount,
|
||||||
oauthCallback,
|
oauthCallback,
|
||||||
};
|
};
|
||||||
@@ -185,7 +185,20 @@ const myGains = async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Dates publiques du jeu-concours, affichées sur la page d'accueil : elles
|
||||||
|
// suivent la date de début fixée par l'admin.
|
||||||
|
const getPublicGameWindow = async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { configured, phase, startDate, participationDeadline, claimDeadline } = await getGameWindow();
|
||||||
|
return res.json({ configured, phase, startDate, participationDeadline, claimDeadline });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Erreur lecture dates du jeu:", error);
|
||||||
|
return res.status(500).json({ message: "Erreur serveur lors de la lecture des dates du jeu-concours." });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
participate,
|
participate,
|
||||||
myGains,
|
myGains,
|
||||||
|
getPublicGameWindow,
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
const jwt = require("jsonwebtoken");
|
const jwt = require("jsonwebtoken");
|
||||||
const prisma = require("../config/prisma");
|
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) => {
|
const authMiddleware = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const authHeader = req.headers.authorization;
|
const authHeader = req.headers.authorization;
|
||||||
@@ -25,6 +30,7 @@ const authMiddleware = async (req, res, next) => {
|
|||||||
role: true,
|
role: true,
|
||||||
provider: true,
|
provider: true,
|
||||||
password: true,
|
password: true,
|
||||||
|
mustChangePassword: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -34,6 +40,14 @@ const authMiddleware = async (req, res, next) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const route = `${req.baseUrl}${req.path}`.replace(/\/+$/, "");
|
||||||
|
if (user.mustChangePassword && !ALLOWED_WHILE_PASSWORD_CHANGE_REQUIRED.includes(route)) {
|
||||||
|
return res.status(403).json({
|
||||||
|
message: "Vous devez choisir un nouveau mot de passe avant de continuer.",
|
||||||
|
code: "PASSWORD_CHANGE_REQUIRED",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// req.user est renvoyé tel quel par /api/auth/me : on remplace le hash par
|
// 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
|
// un simple booléen, qui suffit au profil pour proposer « définir » ou
|
||||||
// « modifier » son mot de passe.
|
// « modifier » son mot de passe.
|
||||||
|
|||||||
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,6 +4,9 @@ const {
|
|||||||
getUsers,
|
getUsers,
|
||||||
getEmailingContacts,
|
getEmailingContacts,
|
||||||
createEmployee,
|
createEmployee,
|
||||||
|
getEmployees,
|
||||||
|
getEmployeeDeliveries,
|
||||||
|
getDeliveryByTicketCode,
|
||||||
drawWinner,
|
drawWinner,
|
||||||
getGrandPrizeWinner,
|
getGrandPrizeWinner,
|
||||||
setGameSettings,
|
setGameSettings,
|
||||||
@@ -81,6 +84,73 @@ router.get("/emailing", getEmailingContacts);
|
|||||||
*/
|
*/
|
||||||
router.post("/employees", validate(createEmployeeSchema), createEmployee);
|
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
|
* @swagger
|
||||||
* /api/admin/draw:
|
* /api/admin/draw:
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller");
|
const { register, login, verifyEmail, verifyCode, forgotPassword, sendPasswordSetupLink, resetPassword, me, changePassword, setInitialPassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller");
|
||||||
const authMiddleware = require("../middlewares/auth.middleware");
|
const authMiddleware = require("../middlewares/auth.middleware");
|
||||||
const validate = require("../middlewares/validate.middleware");
|
const validate = require("../middlewares/validate.middleware");
|
||||||
|
const verifyCaptcha = require("../middlewares/captcha.middleware");
|
||||||
const { registerSchema, loginSchema } = require("../validations/auth.validation");
|
const { registerSchema, loginSchema } = require("../validations/auth.validation");
|
||||||
const { passport, googleEnabled } = require("../config/passport");
|
const { passport, googleEnabled, facebookEnabled } = require("../config/passport");
|
||||||
|
const getFrontendUrl = require("../utils/frontendUrl");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -33,7 +35,7 @@ const router = express.Router();
|
|||||||
* 201:
|
* 201:
|
||||||
* description: Utilisateur créé
|
* description: Utilisateur créé
|
||||||
*/
|
*/
|
||||||
router.post("/register", validate(registerSchema), register);
|
router.post("/register", verifyCaptcha, validate(registerSchema), register);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@@ -49,7 +51,7 @@ router.post("/register", validate(registerSchema), register);
|
|||||||
* description: Connexion réussie
|
* description: Connexion réussie
|
||||||
*/
|
*/
|
||||||
|
|
||||||
router.post("/login", validate(loginSchema), login);
|
router.post("/login", verifyCaptcha, validate(loginSchema), login);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @swagger
|
* @swagger
|
||||||
@@ -111,16 +113,36 @@ router.get("/verify-email/:token",verifyEmail);
|
|||||||
* type: string
|
* type: string
|
||||||
* format: email
|
* format: email
|
||||||
* example: jean.dupont@gmail.com
|
* example: jean.dupont@gmail.com
|
||||||
|
* captchaToken:
|
||||||
|
* type: string
|
||||||
|
* description: Jeton Cloudflare Turnstile (exigé si TURNSTILE_SECRET_KEY est configurée)
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.
|
* description: Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.
|
||||||
* 400:
|
* 400:
|
||||||
* description: Email requis.
|
* description: Email requis, ou vérification anti-robot manquante.
|
||||||
* 500:
|
* 500:
|
||||||
* description: Erreur serveur.
|
* 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
|
* @swagger
|
||||||
* /api/auth/reset-password/{token}:
|
* /api/auth/reset-password/{token}:
|
||||||
@@ -160,6 +182,37 @@ router.post("/reset-password/:token", resetPassword);
|
|||||||
|
|
||||||
router.post("/verify-code", verifyCode);
|
router.post("/verify-code", verifyCode);
|
||||||
router.patch("/change-password", authMiddleware, changePassword);
|
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);
|
router.delete("/account", authMiddleware, deleteAccount);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,30 +240,73 @@ router.delete("/account", authMiddleware, deleteAccount);
|
|||||||
* 302:
|
* 302:
|
||||||
* description: Redirection vers FRONTEND_URL/auth/callback avec le token.
|
* description: Redirection vers FRONTEND_URL/auth/callback avec le token.
|
||||||
*/
|
*/
|
||||||
if (googleEnabled) {
|
|
||||||
router.get(
|
/**
|
||||||
"/google",
|
* @swagger
|
||||||
passport.authenticate("google", { scope: ["profile", "email"], session: false })
|
* /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(
|
router.get(
|
||||||
"/google/callback",
|
`${path}/callback`,
|
||||||
passport.authenticate("google", {
|
passport.authenticate(strategy, {
|
||||||
session: false,
|
session: false,
|
||||||
failureRedirect: `${process.env.FRONTEND_URL || "http://localhost:5173"}/auth/callback?error=${encodeURIComponent(
|
failureRedirect: `${getFrontendUrl()}/auth/callback?error=${encodeURIComponent(
|
||||||
"Connexion Google refusée."
|
`Connexion ${label} refusée.`
|
||||||
)}`,
|
)}`,
|
||||||
}),
|
}),
|
||||||
oauthCallback
|
oauthCallback
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
// Sans identifiants configurés, on répond explicitement plutôt que de
|
|
||||||
// laisser passport planter au démarrage.
|
|
||||||
const notConfigured = (req, res) =>
|
|
||||||
res.status(503).json({ message: "La connexion Google n'est pas configurée sur ce serveur." });
|
|
||||||
|
|
||||||
router.get("/google", notConfigured);
|
|
||||||
router.get("/google/callback", notConfigured);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mountOAuthRoutes({
|
||||||
|
path: "/google",
|
||||||
|
strategy: "google",
|
||||||
|
label: "Google",
|
||||||
|
enabled: googleEnabled,
|
||||||
|
scope: ["profile", "email"],
|
||||||
|
});
|
||||||
|
|
||||||
|
mountOAuthRoutes({
|
||||||
|
path: "/facebook",
|
||||||
|
strategy: "facebook",
|
||||||
|
label: "Facebook",
|
||||||
|
enabled: facebookEnabled,
|
||||||
|
scope: ["email"],
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -36,7 +36,7 @@ const router = express.Router();
|
|||||||
router.get(
|
router.get(
|
||||||
"/ticket/:code",
|
"/ticket/:code",
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
roleMiddleware("EMPLOYEE", "ADMIN"),
|
roleMiddleware("EMPLOYEE"),
|
||||||
getGainByTicketCode
|
getGainByTicketCode
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ router.get(
|
|||||||
router.patch(
|
router.patch(
|
||||||
"/claim/:id/deliver",
|
"/claim/:id/deliver",
|
||||||
authMiddleware,
|
authMiddleware,
|
||||||
roleMiddleware("EMPLOYEE", "ADMIN"),
|
roleMiddleware("EMPLOYEE"),
|
||||||
deliverClaim
|
deliverClaim
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require("express");
|
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 authMiddleware = require("../middlewares/auth.middleware");
|
||||||
const roleMiddleware = require("../middlewares/role.middleware");
|
const roleMiddleware = require("../middlewares/role.middleware");
|
||||||
const validate = require("../middlewares/validate.middleware");
|
const validate = require("../middlewares/validate.middleware");
|
||||||
@@ -41,4 +41,17 @@ router.post("/participate", authMiddleware, roleMiddleware("CLIENT"), validate(p
|
|||||||
|
|
||||||
router.get("/my-gains", authMiddleware, roleMiddleware("CLIENT"), myGains);
|
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;
|
module.exports = router;
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
|
require('dotenv').config()
|
||||||
|
|
||||||
const bcrypt = require('bcrypt')
|
const bcrypt = require('bcrypt')
|
||||||
const prisma = require('../config/prisma')
|
const prisma = require('../config/prisma')
|
||||||
|
|
||||||
async function createAdmin() {
|
async function createAdmin() {
|
||||||
const email = 'admin@thetiptop.fr'
|
const email = process.env.ADMIN_EMAIL
|
||||||
const password = 'Admin@TipTop2026!'
|
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 } })
|
const existing = await prisma.user.findUnique({ where: { email } })
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
@@ -11,6 +11,11 @@ const PHASES = {
|
|||||||
CLOSED: "CLOSED",
|
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 GAME_DURATION_DAYS = 30;
|
||||||
const CLAIM_EXTENSION_DAYS = 30;
|
const CLAIM_EXTENSION_DAYS = 30;
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
@@ -95,6 +100,7 @@ module.exports = {
|
|||||||
effectiveClaimStatus,
|
effectiveClaimStatus,
|
||||||
participationClosedMessage,
|
participationClosedMessage,
|
||||||
PHASES,
|
PHASES,
|
||||||
|
DEFAULT_START_DATE,
|
||||||
GAME_DURATION_DAYS,
|
GAME_DURATION_DAYS,
|
||||||
CLAIM_EXTENSION_DAYS,
|
CLAIM_EXTENSION_DAYS,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
const { z } = require("zod");
|
const { z } = require("zod");
|
||||||
|
const { emailSchema } = require("./email.validation");
|
||||||
|
|
||||||
const createEmployeeSchema = z.object({
|
const createEmployeeSchema = z.object({
|
||||||
firstname: z.string().min(2),
|
firstname: z.string().min(2),
|
||||||
lastname: z.string().min(2),
|
lastname: z.string().min(2),
|
||||||
email: z.string().email(),
|
// Pas de mot de passe : il est généré côté serveur (voir createEmployee).
|
||||||
password: z.string().min(8),
|
email: emailSchema,
|
||||||
birthdate: z.string().optional(),
|
birthdate: z.string().optional(),
|
||||||
gender: z.string().optional(),
|
gender: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,27 @@
|
|||||||
const { z } = require("zod");
|
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"),
|
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"),
|
lastname: z.string().min(2, "Le nom doit contenir au moins 2 caractères"),
|
||||||
email: z.string().email("Email invalide"),
|
email: emailSchema,
|
||||||
password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"),
|
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(),
|
birthdate: z.string().optional(),
|
||||||
gender: z.string().optional(),
|
gender: z.string().optional(),
|
||||||
marketingOptIn: z.boolean().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({
|
const loginSchema = z.object({
|
||||||
email: z.string().email("Email invalide"),
|
email: emailSchema,
|
||||||
password: z.string().min(1, "Mot de passe requis"),
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -30,7 +30,30 @@ const jourJ = (d) =>
|
|||||||
data: { startDate: new Date(Date.now() - d * DAY) },
|
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 () => {
|
beforeAll(async () => {
|
||||||
|
ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant();
|
||||||
await cleanup();
|
await cleanup();
|
||||||
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
ctx.settingsId = row.id;
|
ctx.settingsId = row.id;
|
||||||
@@ -51,6 +74,7 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
await restaurerLeGagnant(ctx.gagnantSauvegarde);
|
||||||
await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } });
|
await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } });
|
||||||
await cleanup();
|
await cleanup();
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
@@ -67,13 +91,16 @@ describe("Notification du grand gagnant", () => {
|
|||||||
expect(res.body.emailSent).toBe(true);
|
expect(res.body.emailSent).toBe(true);
|
||||||
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
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];
|
const envoi = mailer.sendMail.mock.calls[0][0];
|
||||||
expect(envoi.to).toBe(ctx.client.email);
|
expect(envoi.to).toBe(gagnant.email);
|
||||||
expect(envoi.subject).toMatch(/gros lot/i);
|
expect(envoi.subject).toMatch(/gros lot/i);
|
||||||
expect(envoi.html).toContain("Chloe");
|
expect(envoi.html).toContain(gagnant.firstname);
|
||||||
expect(envoi.html).toContain("360");
|
expect(envoi.html).toContain("360");
|
||||||
|
|
||||||
await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } });
|
await prisma.grandPrizeWinner.deleteMany({});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("un SMTP en panne n invalide pas le tirage", async () => {
|
test("un SMTP en panne n invalide pas le tirage", async () => {
|
||||||
@@ -86,10 +113,11 @@ describe("Notification du grand gagnant", () => {
|
|||||||
expect(res.body.message).toMatch(/contacter directement/);
|
expect(res.body.message).toMatch(/contacter directement/);
|
||||||
|
|
||||||
// Le tirage reste enregistre : c est un acte irreversible.
|
// Le tirage reste enregistre : c est un acte irreversible.
|
||||||
const enBase = await prisma.grandPrizeWinner.findFirst({ where: { userId: ctx.client.id } });
|
const enBase = await prisma.grandPrizeWinner.findFirst();
|
||||||
expect(enBase).not.toBeNull();
|
expect(enBase).not.toBeNull();
|
||||||
|
expect(enBase.userId).toBe(res.body.winner.user.id);
|
||||||
|
|
||||||
await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } });
|
await prisma.grandPrizeWinner.deleteMany({});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("aucun email n est envoye si le tirage est refuse", async () => {
|
test("aucun email n est envoye si le tirage est refuse", async () => {
|
||||||
|
|||||||
@@ -21,13 +21,31 @@ const testEmail = "e2e-flow-test@test.com";
|
|||||||
const ticketCode = "ZZE2ETEST1";
|
const ticketCode = "ZZE2ETEST1";
|
||||||
let token;
|
let token;
|
||||||
let createdTicketId;
|
let createdTicketId;
|
||||||
|
let settingsBackup;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await prisma.user.deleteMany({ where: { email: testEmail } });
|
await prisma.user.deleteMany({ where: { email: testEmail } });
|
||||||
await prisma.ticket.deleteMany({ where: { code: ticketCode } });
|
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 () => {
|
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 } });
|
const user = await prisma.user.findUnique({ where: { email: testEmail } });
|
||||||
if (user) {
|
if (user) {
|
||||||
await prisma.claim.deleteMany({ where: { participation: { userId: user.id } } });
|
await prisma.claim.deleteMany({ where: { participation: { userId: user.id } } });
|
||||||
@@ -48,7 +66,8 @@ describe("THE TIP TOP - FULL E2E FLOW", () => {
|
|||||||
firstname: "E2E",
|
firstname: "E2E",
|
||||||
lastname: "Test",
|
lastname: "Test",
|
||||||
email: testEmail,
|
email: testEmail,
|
||||||
password: "Password123",
|
password: "Password123!",
|
||||||
|
confirmPassword: "Password123!",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(201);
|
expect(res.statusCode).toBe(201);
|
||||||
@@ -62,7 +81,7 @@ describe("THE TIP TOP - FULL E2E FLOW", () => {
|
|||||||
|
|
||||||
const res = await request(app).post("/api/auth/login").send({
|
const res = await request(app).post("/api/auth/login").send({
|
||||||
email: testEmail,
|
email: testEmail,
|
||||||
password: "Password123",
|
password: "Password123!",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -26,7 +26,30 @@ async function cleanup() {
|
|||||||
const setStart = (d) =>
|
const setStart = (d) =>
|
||||||
prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: new Date(Date.now() - d * DAY) } });
|
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 () => {
|
beforeAll(async () => {
|
||||||
|
ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant();
|
||||||
await cleanup();
|
await cleanup();
|
||||||
ctx.settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
ctx.settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
ctx.orig = ctx.settings.startDate;
|
ctx.orig = ctx.settings.startDate;
|
||||||
@@ -46,6 +69,7 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
await restaurerLeGagnant(ctx.gagnantSauvegarde);
|
||||||
await prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: ctx.orig } });
|
await prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: ctx.orig } });
|
||||||
await cleanup();
|
await cleanup();
|
||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
|
|||||||
@@ -33,7 +33,25 @@ async function jourJ(offsetJours) {
|
|||||||
|
|
||||||
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "2h" });
|
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 () => {
|
beforeAll(async () => {
|
||||||
|
ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant();
|
||||||
await cleanup();
|
await cleanup();
|
||||||
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
ctx.startDateOriginale = row ? row.startDate : null;
|
ctx.startDateOriginale = row ? row.startDate : null;
|
||||||
@@ -49,6 +67,7 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
|
await restaurerLeGagnant(ctx.gagnantSauvegarde);
|
||||||
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||||
if (ctx.startDateOriginale) {
|
if (ctx.startDateOriginale) {
|
||||||
if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: ctx.startDateOriginale } });
|
if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: ctx.startDateOriginale } });
|
||||||
@@ -68,6 +87,11 @@ describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => {
|
|||||||
log("phase :", vue.body.gameWindow.phase, "| participations ouvertes :", vue.body.gameWindow.isParticipationOpen);
|
log("phase :", vue.body.gameWindow.phase, "| participations ouvertes :", vue.body.gameWindow.isParticipationOpen);
|
||||||
expect(vue.body.gameWindow.phase).toBe("NOT_CONFIGURED");
|
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] });
|
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);
|
log("un client tente de jouer ->", essai.status, essai.body.message);
|
||||||
expect(essai.status).toBe(410);
|
expect(essai.status).toBe(410);
|
||||||
@@ -89,6 +113,12 @@ describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => {
|
|||||||
log("PUT game-settings ->", r.status, "| phase :", r.body.gameWindow.phase);
|
log("PUT game-settings ->", r.status, "| phase :", r.body.gameWindow.phase);
|
||||||
expect(r.body.gameWindow.phase).toBe("SCHEDULED");
|
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] });
|
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("client joue avant ouverture ->", anticipe.status);
|
||||||
log("message :", JSON.stringify(anticipe.body.message));
|
log("message :", JSON.stringify(anticipe.body.message));
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ decrire("Inscription Google et definition du mot de passe", () => {
|
|||||||
test("une confirmation qui ne correspond pas est refusee", async () => {
|
test("une confirmation qui ne correspond pas est refusee", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post("/api/auth/reset-password/" + ctxToken)
|
.post("/api/auth/reset-password/" + ctxToken)
|
||||||
.send({ password: "MonMotDePasse1", confirmPassword: "AutreMotDePasse2" });
|
.send({ password: "MonMotDePasse1!", confirmPassword: "AutreMotDePasse2!" });
|
||||||
|
|
||||||
expect(res.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
expect(res.body.message).toMatch(/ne correspondent pas/);
|
expect(res.body.message).toMatch(/ne correspondent pas/);
|
||||||
@@ -105,14 +105,23 @@ decrire("Inscription Google et definition du mot de passe", () => {
|
|||||||
expect(res.body.message).toMatch(/8 caractères/);
|
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 () => {
|
test("le mot de passe defini permet ensuite de se connecter et de le changer", async () => {
|
||||||
const ok = await request(app)
|
const ok = await request(app)
|
||||||
.post("/api/auth/reset-password/" + ctxToken)
|
.post("/api/auth/reset-password/" + ctxToken)
|
||||||
.send({ password: "MonMotDePasse1", confirmPassword: "MonMotDePasse1" })
|
.send({ password: "MonMotDePasse1!", confirmPassword: "MonMotDePasse1!" })
|
||||||
.expect(200);
|
.expect(200);
|
||||||
expect(ok.body.message).toMatch(/réinitialisé/);
|
expect(ok.body.message).toMatch(/réinitialisé/);
|
||||||
|
|
||||||
const login = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "MonMotDePasse1" }).expect(200);
|
const login = await request(app).post("/api/auth/login").send({ email: EMAIL, password: "MonMotDePasse1!" }).expect(200);
|
||||||
expect(login.body.token).toBeDefined();
|
expect(login.body.token).toBeDefined();
|
||||||
|
|
||||||
// Le jeton a bien ete consomme.
|
// Le jeton a bien ete consomme.
|
||||||
@@ -125,8 +134,16 @@ decrire("Inscription Google et definition du mot de passe", () => {
|
|||||||
const change = await request(app)
|
const change = await request(app)
|
||||||
.patch("/api/auth/change-password")
|
.patch("/api/auth/change-password")
|
||||||
.set("Authorization", "Bearer " + login.body.token)
|
.set("Authorization", "Bearer " + login.body.token)
|
||||||
.send({ currentPassword: "MonMotDePasse1", newPassword: "EncoreUnAutre2" });
|
.send({ currentPassword: "MonMotDePasse1!", newPassword: "EncoreUnAutre2!" });
|
||||||
expect(change.status).toBe(200);
|
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 () => {
|
test("une seconde connexion Google ne renvoie pas de nouveau lien", async () => {
|
||||||
|
|||||||
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/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,7 +15,8 @@ const payload = {
|
|||||||
firstname: "Camille",
|
firstname: "Camille",
|
||||||
lastname: "Martin",
|
lastname: "Martin",
|
||||||
email: testEmail,
|
email: testEmail,
|
||||||
password: "Password123",
|
password: "Password123!",
|
||||||
|
confirmPassword: "Password123!",
|
||||||
birthdate: "1990-05-14",
|
birthdate: "1990-05-14",
|
||||||
gender: "Femme",
|
gender: "Femme",
|
||||||
};
|
};
|
||||||
|
|||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
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 = "";
|
||||||
@@ -40,6 +40,15 @@ services:
|
|||||||
API_PUBLIC_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
|
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: >
|
command: >
|
||||||
sh -c "
|
sh -c "
|
||||||
npx prisma migrate deploy &&
|
npx prisma migrate deploy &&
|
||||||
|
|||||||
@@ -40,6 +40,15 @@ services:
|
|||||||
API_PUBLIC_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
|
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: >
|
command: >
|
||||||
sh -c "
|
sh -c "
|
||||||
npx prisma migrate deploy &&
|
npx prisma migrate deploy &&
|
||||||
|
|||||||
@@ -38,7 +38,16 @@ services:
|
|||||||
JWT_SECRET: prod_secret_change_me
|
JWT_SECRET: prod_secret_change_me
|
||||||
APP_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
|
APP_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
|
||||||
API_PUBLIC_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
|
API_PUBLIC_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
|
||||||
FRONTEND_URL: https://dsp5-archi-o24a-4-5-g4.fr
|
FRONTEND_URL: https://dsp5-archi-o24a-4-5-g4.fr,https://www.dsp5-archi-o24a-4-5-g4.fr
|
||||||
|
|
||||||
|
# Injectés par le pipeline, jamais commités. Vides, les routes de
|
||||||
|
# connexion externe répondent 503 sans bloquer le démarrage.
|
||||||
|
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}"
|
||||||
|
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
|
||||||
|
FACEBOOK_APP_ID: "${FACEBOOK_APP_ID}"
|
||||||
|
FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET}"
|
||||||
|
# Vide, la vérification anti-robot Turnstile est désactivée.
|
||||||
|
TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY}"
|
||||||
|
|
||||||
command: >
|
command: >
|
||||||
sh -c "
|
sh -c "
|
||||||
|
|||||||
1
frontend/.gitignore
vendored
@@ -11,6 +11,7 @@ node_modules
|
|||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
*.local
|
*.local
|
||||||
|
.env
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
FROM node:22-alpine AS build
|
FROM node:22-slim AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
RUN npx playwright install --with-deps chromium
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
@@ -13,6 +14,9 @@ ARG VITE_GA_ID
|
|||||||
ENV VITE_API_URL=${VITE_API_URL}
|
ENV VITE_API_URL=${VITE_API_URL}
|
||||||
ENV VITE_GA_ID=${VITE_GA_ID}
|
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
|
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)
|
L’interface communique avec l’API backend et propose des parcours adaptés aux visiteurs, clients, employés et administrateurs.
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
|
||||||
|
|
||||||
## 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']),
|
globalIgnores(['dist']),
|
||||||
{
|
{
|
||||||
files: ['**/*.{js,jsx}'],
|
files: ['**/*.{js,jsx}'],
|
||||||
|
ignores: ['scripts/**'],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
reactHooks.configs.flat.recommended,
|
reactHooks.configs.flat.recommended,
|
||||||
@@ -18,4 +19,13 @@ export default defineConfig([
|
|||||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -5,7 +5,43 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="Participez au jeu-concours Thé Tip Top : saisissez le code de votre ticket et découvrez immédiatement votre lot." />
|
<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>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location / {
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
frontend/package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "ttt-front",
|
"name": "ttt-front",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@marsidev/react-turnstile": "^1.6.1",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-router-dom": "^7.17.0"
|
"react-router-dom": "^7.17.0"
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
@@ -802,6 +804,15 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@marsidev/react-turnstile": {
|
||||||
|
"version": "1.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@marsidev/react-turnstile/-/react-turnstile-1.6.1.tgz",
|
||||||
|
"integrity": "sha512-ojI9NLYv821IRKi7KraTkxTto6KC/m58hNNyKxvYxm/cQBu5MI5eMkaiJs2JSQBFiXE3LPQI3seJVjv/2lgALA==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^17.0.2 || ^18.0.0 || ^19.0",
|
||||||
|
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||||
@@ -3419,6 +3430,33 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||||
|
"dev": true,
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.63.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.63.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||||
|
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||||
|
"dev": true,
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build && node scripts/prerender.js",
|
||||||
|
"prerender": "node scripts/prerender.js",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@marsidev/react-turnstile": "^1.6.1",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-router-dom": "^7.17.0"
|
"react-router-dom": "^7.17.0"
|
||||||
@@ -32,6 +34,7 @@
|
|||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
|
"playwright": "^1.62.1",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.12",
|
||||||
"vitest": "^4.1.10"
|
"vitest": "^4.1.10"
|
||||||
|
|||||||
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
@@ -3,10 +3,13 @@ User-agent: *
|
|||||||
# Pages publiques autorisées
|
# Pages publiques autorisées
|
||||||
Allow: /
|
Allow: /
|
||||||
Allow: /accueil
|
Allow: /accueil
|
||||||
|
Allow: /notre-concept
|
||||||
Allow: /regles-jeu
|
Allow: /regles-jeu
|
||||||
Allow: /contact
|
Allow: /contact
|
||||||
Allow: /mentions-legales
|
Allow: /mentions-legales
|
||||||
Allow: /confidentialite
|
Allow: /confidentialite
|
||||||
|
Allow: /accessibilite
|
||||||
|
Allow: /cgu
|
||||||
Allow: /sitemap
|
Allow: /sitemap
|
||||||
Allow: /inscription
|
Allow: /inscription
|
||||||
Allow: /connexion
|
Allow: /connexion
|
||||||
@@ -20,4 +23,4 @@ Disallow: /saisir-code-unique
|
|||||||
Disallow: /perdu
|
Disallow: /perdu
|
||||||
|
|
||||||
# Sitemap
|
# Sitemap
|
||||||
Sitemap: https://thetiptop.fr/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)
|
||||||
|
})
|
||||||
@@ -7,7 +7,10 @@ import Connexion from './pages/Connexion'
|
|||||||
import Inscription from './pages/Inscription'
|
import Inscription from './pages/Inscription'
|
||||||
import AuthCallback from './pages/AuthCallback'
|
import AuthCallback from './pages/AuthCallback'
|
||||||
import DefinirMotDePasse from './pages/DefinirMotDePasse'
|
import DefinirMotDePasse from './pages/DefinirMotDePasse'
|
||||||
|
import MotDePasseOublie from './pages/MotDePasseOublie'
|
||||||
|
import NouveauMotDePasse from './pages/NouveauMotDePasse'
|
||||||
import Accueil from './pages/Accueil'
|
import Accueil from './pages/Accueil'
|
||||||
|
import NotreConcept from './pages/NotreConcept'
|
||||||
import ReglesJeu from './pages/ReglesJeu'
|
import ReglesJeu from './pages/ReglesJeu'
|
||||||
import MesGains from './pages/MesGains'
|
import MesGains from './pages/MesGains'
|
||||||
import Employe from './pages/Employe'
|
import Employe from './pages/Employe'
|
||||||
@@ -32,10 +35,13 @@ export default function App() {
|
|||||||
<Route path="/connexion" element={<Connexion />} />
|
<Route path="/connexion" element={<Connexion />} />
|
||||||
<Route path="/inscription" element={<Inscription />} />
|
<Route path="/inscription" element={<Inscription />} />
|
||||||
<Route path="/auth/callback" element={<AuthCallback />} />
|
<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="/definir-mot-de-passe/:token" element={<DefinirMotDePasse />} />
|
||||||
|
<Route path="/nouveau-mot-de-passe" element={<NouveauMotDePasse />} />
|
||||||
|
|
||||||
{/* Pages publiques */}
|
{/* Pages publiques */}
|
||||||
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />
|
<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="/regles-jeu" element={<Layout><ReglesJeu /></Layout>} />
|
||||||
<Route path="/mentions-legales" element={<Layout><MentionsLegales /></Layout>} />
|
<Route path="/mentions-legales" element={<Layout><MentionsLegales /></Layout>} />
|
||||||
<Route path="/confidentialite" element={<Layout><Confidentialite /></Layout>} />
|
<Route path="/confidentialite" element={<Layout><Confidentialite /></Layout>} />
|
||||||
|
|||||||
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 |
|
Before Width: | Height: | Size: 7.2 MiB |
BIN
frontend/src/assets/img/img-contact.webp
Normal file
|
After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 18 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"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ export default function Footer() {
|
|||||||
<footer className="bg-[#006B6B] text-white">
|
<footer className="bg-[#006B6B] text-white">
|
||||||
|
|
||||||
{/* Partie principale */}
|
{/* Partie principale */}
|
||||||
<div className="max-w-7xl mx-auto px-6 py-10 grid grid-cols-2 md:grid-cols-4 gap-8">
|
<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 */}
|
{/* Logo */}
|
||||||
<div>
|
<div>
|
||||||
@@ -57,6 +57,16 @@ export default function Footer() {
|
|||||||
</FooterLink>
|
</FooterLink>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<FooterLink
|
||||||
|
to="/notre-concept"
|
||||||
|
pathname={pathname}
|
||||||
|
className="hover:text-white transition"
|
||||||
|
>
|
||||||
|
Notre concept
|
||||||
|
</FooterLink>
|
||||||
|
</li>
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<FooterLink
|
<FooterLink
|
||||||
to="/regles-jeu"
|
to="/regles-jeu"
|
||||||
@@ -179,7 +189,6 @@ export default function Footer() {
|
|||||||
>
|
>
|
||||||
01 84 80 20 34
|
01 84 80 20 34
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="mailto:contact@thetiptop.fr"
|
href="mailto:contact@thetiptop.fr"
|
||||||
className="inline-block mt-3 font-medium hover:text-teal-300"
|
className="inline-block mt-3 font-medium hover:text-teal-300"
|
||||||
@@ -198,9 +207,23 @@ export default function Footer() {
|
|||||||
|
|
||||||
{/* Barre basse */}
|
{/* Barre basse */}
|
||||||
<div className="border-t border-teal-800">
|
<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">
|
<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-200 text-sm text-center md:text-left">
|
<p className="text-teal-100 text-sm text-center md:text-left">
|
||||||
© {new Date().getFullYear()} Thé Tip Top. Tous droits réservés.
|
© {new Date().getFullYear()} Thé Tip Top. Tous droits réservés.
|
||||||
<br className="md:hidden" />
|
<br className="md:hidden" />
|
||||||
<span className="md:before:content-['_—_']">
|
<span className="md:before:content-['_—_']">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import logo from '../assets/img/logo.png'
|
import logo from '../assets/img/logo.webp'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
@@ -19,7 +19,9 @@ export default function Header() {
|
|||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ to: '/accueil', label: 'Accueil' },
|
{ to: '/accueil', label: 'Accueil' },
|
||||||
|
{ to: '/notre-concept', label: 'Notre concept' },
|
||||||
{ to: '/regles-jeu', label: 'Règles du jeu' },
|
{ to: '/regles-jeu', label: 'Règles du jeu' },
|
||||||
|
{ to: '/contact', label: 'Contact' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const initials = user
|
const initials = user
|
||||||
|
|||||||
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Navigate } from 'react-router-dom'
|
import { Navigate } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { homeFor, INITIAL_PASSWORD_PATH } from '../config/roles'
|
||||||
|
|
||||||
export default function ProtectedRoute({ children, roles }) {
|
export default function ProtectedRoute({ children, roles }) {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
@@ -8,9 +9,14 @@ export default function ProtectedRoute({ children, roles }) {
|
|||||||
return <Navigate to="/connexion" replace />
|
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)) {
|
if (roles && !roles.includes(user.role)) {
|
||||||
const redirects = { ADMIN: '/admin', EMPLOYEE: '/employe', CLIENT: '/mes-gains' }
|
return <Navigate to={homeFor(user)} replace />
|
||||||
return <Navigate to={redirects[user.role] || '/connexion'} replace />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return children
|
return children
|
||||||
|
|||||||
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'
|
||||||
|
}
|
||||||
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])
|
||||||
|
}
|
||||||
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";
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
|
||||||
export default function Accessibilite() {
|
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 (
|
return (
|
||||||
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
<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">
|
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
@@ -31,7 +38,7 @@ export default function Accessibilite() {
|
|||||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
<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
|
Si vous rencontrez un obstacle à l'accessibilité sur le site, vous pouvez nous en faire
|
||||||
part afin que nous puissions y remédier :<br />
|
part afin que nous puissions y remédier :<br />
|
||||||
<strong>Email :</strong> contact@thetiptop.fr
|
<strong>Email :</strong> thetiptop.gp4@gmail.com
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,221 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
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() {
|
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 (
|
return (
|
||||||
<div className="bg-gray-50 min-h-screen">
|
<div className="bg-gray-50 min-h-screen">
|
||||||
|
|
||||||
{/* Hero */}
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }} />
|
||||||
<section className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16 md:py-24">
|
|
||||||
<div className="text-center max-w-3xl mx-auto">
|
<section className="relative bg-[#006B6B] overflow-hidden">
|
||||||
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-[#006B6B] mb-4 sm:mb-6 leading-tight">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 relative py-10 lg:py-0 lg:h-[680px] xl:h-[780px]">
|
||||||
Bienvenue au jeu-concours Thé Tip Top
|
|
||||||
</h1>
|
<div
|
||||||
<p className="text-base sm:text-lg md:text-xl text-gray-600 mb-8 sm:mb-10">
|
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"
|
||||||
À l'occasion de l'ouverture de notre 10ᵉ boutique à Nice, participez à notre jeu-concours
|
aria-hidden="true"
|
||||||
et tentez de gagner des lots exceptionnels ! Tous les tickets sont gagnants, alors n'attendez
|
/>
|
||||||
plus pour découvrir votre lot.
|
|
||||||
|
<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>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
|
<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
|
<Link
|
||||||
to="/saisir-code-unique"
|
to="/saisir-code-unique"
|
||||||
className="bg-[#006B6B] hover:bg-[#005555] text-white font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base"
|
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
|
Tester mon code
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/regles-jeu"
|
to="/regles-jeu"
|
||||||
className="border border-[#006B6B] text-[#006B6B] hover:bg-[#005555] hover:text-white font-semibold px-6 sm:px-8 py-3 sm:py-4 rounded-xl transition-colors text-sm sm:text-base"
|
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
|
Découvrir les règles
|
||||||
</Link>
|
</Link>
|
||||||
@@ -32,22 +223,47 @@ export default function Accueil() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Comment participer */}
|
<section className="max-w-5xl mx-auto px-4 pt-6 sm:px-6 pb-12 sm:pb-20">
|
||||||
<section className="max-w-5xl mx-auto px-4 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">
|
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center">
|
||||||
Comment participer ?
|
Comment participer ?
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
<ol className="grid grid-cols-1 sm:grid-cols-3 gap-4 list-none">
|
||||||
{[
|
{[
|
||||||
{ num: '1', text: 'Achat de 49 € minimum' },
|
{ num: '1', text: "Achat de 49 € dans notre boutique de Nice, code sur le ticket" },
|
||||||
{ num: '2', text: 'Recevez votre code unique' },
|
{ num: '2', text: 'Créez votre compte et saisissez le code' },
|
||||||
{ num: '3', text: 'Saisissez le code sur le site' },
|
{ num: '3', text: 'Découvrez et retirez votre lot en boutique' },
|
||||||
{ num: '4', text: 'Découvrez votre lot' },
|
|
||||||
].map((step) => (
|
].map((step) => (
|
||||||
<div key={step.num} className="bg-white rounded-2xl shadow p-4 sm:p-6 text-center">
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
|
</details>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi } from '../services/admin'
|
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi, createEmployeeApi } from '../services/admin'
|
||||||
import { searchTicketApi, deliverClaimApi } from '../services/employee'
|
import EmployeeDeliveries from '../components/admin/EmployeeDeliveries'
|
||||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||||
|
import { formatDay, formatRange, gameDatesFromStart } from '../utils/gameDates'
|
||||||
|
|
||||||
function StatCard({ label, value, sub }) {
|
function StatCard({ label, value, sub }) {
|
||||||
return (
|
return (
|
||||||
@@ -39,6 +40,13 @@ const PHASE_TITLES = {
|
|||||||
CLOSED: 'Jeu-concours terminé',
|
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) {
|
function formatDate(dateStr) {
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||||
day: '2-digit', month: 'long', year: 'numeric',
|
day: '2-digit', month: 'long', year: 'numeric',
|
||||||
@@ -63,24 +71,25 @@ export default function Admin() {
|
|||||||
const [settingsError, setSettingsError] = useState('')
|
const [settingsError, setSettingsError] = useState('')
|
||||||
const [settingsOk, setSettingsOk] = useState('')
|
const [settingsOk, setSettingsOk] = useState('')
|
||||||
|
|
||||||
// Remise de lot
|
|
||||||
const [ticketCode, setTicketCode] = useState('')
|
|
||||||
const [ticketResult, setTicketResult] = useState(null)
|
|
||||||
const [ticketSearching, setTicketSearching] = useState(false)
|
|
||||||
const [ticketDelivering, setTicketDelivering] = useState(false)
|
|
||||||
const [ticketSearchErr, setTicketSearchErr] = useState('')
|
|
||||||
const [ticketDeliverErr, setTicketDeliverErr] = useState('')
|
|
||||||
const [ticketSuccess, setTicketSuccess] = useState(false)
|
|
||||||
|
|
||||||
// Contacts emailing
|
// Contacts emailing
|
||||||
const [emailingContacts, setEmailingContacts] = useState(null)
|
const [emailingContacts, setEmailingContacts] = useState(null)
|
||||||
const [emailingLoading, setEmailingLoading] = useState(false)
|
const [emailingLoading, setEmailingLoading] = useState(false)
|
||||||
const [emailingError, setEmailingError] = useState('')
|
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(() => {
|
useEffect(() => {
|
||||||
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
|
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
|
||||||
.then(([s, u, w]) => {
|
.then(([s, u, w]) => {
|
||||||
setStats(s)
|
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)
|
setUsers(u.users)
|
||||||
setWinner(w)
|
setWinner(w)
|
||||||
})
|
})
|
||||||
@@ -100,8 +109,8 @@ export default function Admin() {
|
|||||||
// convertit en ISO pour que le serveur reçoive un instant sans ambiguïté.
|
// convertit en ISO pour que le serveur reçoive un instant sans ambiguïté.
|
||||||
const data = await setGameSettingsApi(new Date(startDateInput).toISOString())
|
const data = await setGameSettingsApi(new Date(startDateInput).toISOString())
|
||||||
setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev))
|
setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev))
|
||||||
setSettingsOk(data.message)
|
setSettingsOk(`${data.message} La page d'accueil affiche désormais ces dates.`)
|
||||||
setStartDateInput('')
|
setStartDateInput(toDateTimeLocalValue(data.gameWindow.startDate))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!handleAuthError(err)) setSettingsError(err.message)
|
if (!handleAuthError(err)) setSettingsError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -145,10 +154,7 @@ export default function Admin() {
|
|||||||
const csv = [header, ...rows]
|
const csv = [header, ...rows]
|
||||||
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';'))
|
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';'))
|
||||||
.join('\n')
|
.join('\n')
|
||||||
// Le BOM en tete du fichier est indispensable : sans lui, Excel ouvre le
|
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' })
|
||||||
// CSV en ANSI et casse tous les accents. Ne pas le supprimer.
|
|
||||||
// eslint-disable-next-line no-irregular-whitespace
|
|
||||||
const blob = new Blob([`${csv}`], { type: 'text/csv;charset=utf-8;' })
|
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
@@ -157,43 +163,20 @@ export default function Admin() {
|
|||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleTicketSearch = async (e) => {
|
const handleCreateEmployee = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setTicketResult(null)
|
setEmployeeError('')
|
||||||
setTicketSearchErr('')
|
setEmployeeOk('')
|
||||||
setTicketDeliverErr('')
|
setEmployeeSaving(true)
|
||||||
setTicketSuccess(false)
|
|
||||||
setTicketSearching(true)
|
|
||||||
try {
|
try {
|
||||||
const data = await searchTicketApi(ticketCode.trim())
|
const data = await createEmployeeApi(employeeForm)
|
||||||
setTicketResult(data)
|
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) {
|
} catch (err) {
|
||||||
if (!handleAuthError(err)) setTicketSearchErr(err.message)
|
if (!handleAuthError(err)) setEmployeeError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
setTicketSearching(false)
|
setEmployeeSaving(false)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleTicketDeliver = async () => {
|
|
||||||
if (!ticketResult?.claim?.id) return
|
|
||||||
setTicketDeliverErr('')
|
|
||||||
setTicketDelivering(true)
|
|
||||||
try {
|
|
||||||
await deliverClaimApi(ticketResult.claim.id)
|
|
||||||
setTicketResult((prev) => ({
|
|
||||||
...prev,
|
|
||||||
claim: { ...prev.claim, status: 'DELIVERED', claimedAt: new Date().toISOString() },
|
|
||||||
}))
|
|
||||||
setTicketSuccess(true)
|
|
||||||
setStats((prev) => prev ? {
|
|
||||||
...prev,
|
|
||||||
claimsPending: Math.max(0, (prev.claimsPending ?? 1) - 1),
|
|
||||||
claimsDelivered: (prev.claimsDelivered ?? 0) + 1,
|
|
||||||
} : prev)
|
|
||||||
} catch (err) {
|
|
||||||
if (!handleAuthError(err)) setTicketDeliverErr(err.message)
|
|
||||||
} finally {
|
|
||||||
setTicketDelivering(false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,6 +194,15 @@ export default function Admin() {
|
|||||||
|
|
||||||
const winnerUser = winner?.winner?.user
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
|
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
|
||||||
<div className="max-w-5xl mx-auto space-y-6">
|
<div className="max-w-5xl mx-auto space-y-6">
|
||||||
@@ -244,8 +236,11 @@ export default function Admin() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSaveGameSettings} className="mt-4 pt-4 border-t border-gray-200 flex flex-wrap items-end gap-3">
|
<form
|
||||||
<div className="flex-1 min-w-[220px]">
|
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">
|
<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'}
|
{stats.gameWindow.configured ? 'Corriger la date de début' : 'Date de début du jeu-concours'}
|
||||||
</label>
|
</label>
|
||||||
@@ -261,12 +256,23 @@ export default function Admin() {
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={settingsSaving || !startDateInput}
|
disabled={settingsSaving || !startDateInput}
|
||||||
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
|
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'}
|
{settingsSaving ? 'Enregistrement…' : stats.gameWindow.configured ? 'Mettre à jour' : 'Lancer le jeu'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</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">
|
<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.
|
Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.
|
||||||
</p>
|
</p>
|
||||||
@@ -418,94 +424,65 @@ export default function Admin() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Remise de lot ── */}
|
{/* ── Création d'un compte employé ── */}
|
||||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Remise de lot</h2>
|
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Créer un compte employé</h2>
|
||||||
|
<p className="text-xs sm:text-sm text-gray-500 mb-4">
|
||||||
|
Donne accès à l'espace employé (remise des lots en boutique). L'admin ne gère pas les remises lui-même.
|
||||||
|
L'employé reçoit par email un mot de passe provisoire, qu'il devra changer à sa première connexion.
|
||||||
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleTicketSearch} className="flex gap-2 mb-4">
|
<form onSubmit={handleCreateEmployee} className="grid sm:grid-cols-2 gap-3">
|
||||||
<label htmlFor="admin-ticket-code" className="sr-only">Code ticket</label>
|
<div>
|
||||||
|
<label htmlFor="employee-firstname" className="block text-xs font-medium text-gray-700 mb-1">Prénom</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="admin-ticket-code"
|
id="employee-firstname"
|
||||||
value={ticketCode}
|
value={employeeForm.firstname}
|
||||||
onChange={(e) => setTicketCode(e.target.value.toUpperCase())}
|
onChange={(e) => setEmployeeForm({ ...employeeForm, firstname: e.target.value })}
|
||||||
placeholder="Code ticket (10 caractères)"
|
required
|
||||||
maxLength={10}
|
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]"
|
||||||
className="flex-1 min-w-0 border border-gray-200 rounded-lg px-4 py-2.5 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
|
||||||
/>
|
/>
|
||||||
|
</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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={ticketSearching || ticketCode.trim().length === 0}
|
disabled={employeeSaving}
|
||||||
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2.5 rounded-lg transition-colors text-sm whitespace-nowrap"
|
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"
|
||||||
>
|
>
|
||||||
{ticketSearching ? 'Recherche…' : 'Rechercher'}
|
{employeeSaving ? 'Création…' : 'Créer le compte'}
|
||||||
</button>
|
</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>
|
</form>
|
||||||
|
|
||||||
{ticketSearchErr && (
|
|
||||||
<p className="text-red-500 text-sm mb-3">{ticketSearchErr}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{ticketResult && (
|
|
||||||
<div className="border border-gray-100 rounded-xl overflow-hidden">
|
|
||||||
{ticketSuccess && (
|
|
||||||
<div className="bg-green-500 px-4 py-2.5 flex items-center gap-2">
|
|
||||||
<svg className="w-4 h-4 text-white flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
||||||
</svg>
|
|
||||||
<p className="text-white text-sm font-medium">Lot remis avec succès.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="px-4 py-3 flex items-start justify-between gap-3 border-b border-gray-100">
|
|
||||||
<div>
|
|
||||||
<p className="font-semibold text-gray-900 text-sm">{ticketResult.lot.name}</p>
|
|
||||||
<p className="text-gray-500 text-xs mt-0.5">{ticketResult.lot.description}</p>
|
|
||||||
{ticketResult.lot.value && <p className="text-[#006B6B] text-xs font-semibold mt-1">{ticketResult.lot.value} €</p>}
|
|
||||||
</div>
|
|
||||||
<span className={`flex-shrink-0 text-xs font-semibold px-2.5 py-1 rounded-full ${
|
|
||||||
ticketResult.claim?.status === 'DELIVERED' ? 'bg-green-100 text-green-700'
|
|
||||||
: ticketResult.claim?.status === 'CANCELLED' ? 'bg-gray-100 text-gray-500'
|
|
||||||
: 'bg-yellow-100 text-yellow-700'
|
|
||||||
}`}>
|
|
||||||
{ticketResult.claim?.status === 'DELIVERED' ? 'Remis'
|
|
||||||
: ticketResult.claim?.status === 'CANCELLED' ? 'Expiré'
|
|
||||||
: 'À remettre'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 py-3 flex items-center gap-3 border-b border-gray-100">
|
{/* ── Employés et remises des lots ── */}
|
||||||
<div className="w-8 h-8 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
|
<EmployeeDeliveries refreshKey={employeesVersion} onAuthError={handleAuthError} />
|
||||||
{(ticketResult.client.firstname?.[0] ?? '?').toUpperCase()}{(ticketResult.client.lastname?.[0] ?? '?').toUpperCase()}
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-gray-800">{ticketResult.client.firstname} {ticketResult.client.lastname}</p>
|
|
||||||
<p className="text-xs text-gray-500">{ticketResult.client.email}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-4 py-3 space-y-2">
|
|
||||||
{ticketDeliverErr && <p className="text-red-500 text-xs">{ticketDeliverErr}</p>}
|
|
||||||
|
|
||||||
{ticketResult.claim?.status === 'PENDING' && !ticketSuccess && (
|
|
||||||
<button
|
|
||||||
onClick={handleTicketDeliver}
|
|
||||||
disabled={ticketDelivering}
|
|
||||||
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold py-2.5 rounded-lg transition-colors text-sm"
|
|
||||||
>
|
|
||||||
{ticketDelivering ? 'Confirmation…' : 'Confirmer la remise du lot'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => { setTicketCode(''); setTicketResult(null); setTicketSuccess(false); setTicketSearchErr(''); setTicketDeliverErr('') }}
|
|
||||||
className="w-full border border-gray-200 text-gray-500 hover:bg-gray-50 py-2 rounded-lg transition-colors text-sm"
|
|
||||||
>
|
|
||||||
Rechercher un autre code
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Tirage au sort ── */}
|
{/* ── Tirage au sort ── */}
|
||||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
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
|
// 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.
|
// avec le JWT et le profil encodés dans l'URL.
|
||||||
@@ -8,46 +9,40 @@ export default function AuthCallback() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [searchParams] = useSearchParams()
|
const [searchParams] = useSearchParams()
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
|
|
||||||
const errorParam = searchParams.get('error')
|
|
||||||
const [error, setError] = useState(errorParam || '')
|
|
||||||
|
|
||||||
// StrictMode monte deux fois en dev : on ne traite le token qu'une seule fois.
|
|
||||||
const handled = useRef(false)
|
const handled = useRef(false)
|
||||||
|
|
||||||
useEffect(() => {
|
const errorParam = searchParams.get('error')
|
||||||
if (handled.current) return
|
|
||||||
handled.current = true
|
|
||||||
|
|
||||||
if (errorParam) return
|
|
||||||
|
|
||||||
const token = searchParams.get('token')
|
const token = searchParams.get('token')
|
||||||
const encodedUser = searchParams.get('user')
|
const encodedUser = searchParams.get('user')
|
||||||
|
|
||||||
if (!token || !encodedUser) {
|
let error = ''
|
||||||
setTimeout(() => {
|
let user = null
|
||||||
setError('Réponse de connexion incomplète.')
|
|
||||||
}, 0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
if (errorParam) {
|
||||||
const user = JSON.parse(atob(encodedUser))
|
error = errorParam
|
||||||
login(user, token)
|
} else if (!token || !encodedUser) {
|
||||||
|
error = 'Réponse de connexion incomplète.'
|
||||||
if (user.role === 'ADMIN') {
|
|
||||||
navigate('/admin', { replace: true })
|
|
||||||
} else if (user.role === 'EMPLOYEE') {
|
|
||||||
navigate('/employe', { replace: true })
|
|
||||||
} else {
|
} else {
|
||||||
navigate('/mes-gains', { replace: true })
|
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 {
|
} catch {
|
||||||
setTimeout(() => {
|
error = 'Réponse de connexion illisible.'
|
||||||
setError('Réponse de connexion illisible.')
|
|
||||||
}, 0)
|
|
||||||
}
|
}
|
||||||
}, [searchParams, errorParam, login, navigate])
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (handled.current || !user) return
|
||||||
|
|
||||||
|
handled.current = true
|
||||||
|
|
||||||
|
login(user, token)
|
||||||
|
navigate(homeFor(user), { replace: true })
|
||||||
|
}, [user, token, login, navigate])
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
|
||||||
export default function Cgu() {
|
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 (
|
return (
|
||||||
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
<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">
|
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
@@ -30,13 +38,23 @@ export default function Cgu() {
|
|||||||
Les présentes Conditions Générales d'Utilisation (CGU) ont pour objet de définir
|
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 à
|
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 »,
|
disposition des utilisateurs le site internet du jeu-concours « 100% Gagnant »,
|
||||||
accessible à l'adresse https://www.thetiptop-concours.fr.
|
accessible à l'adresse https://www.dsp5-archi-o24a-4-5-g4.fr.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Toute utilisation du Site implique l'acceptation pleine et entière des présentes
|
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
|
CGU. En cas de non-acceptation, l'utilisateur est invité à ne pas accéder au Site
|
||||||
ni à utiliser ses services.
|
ni à utiliser ses services.
|
||||||
</p>
|
</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>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -50,10 +68,11 @@ export default function Cgu() {
|
|||||||
non avenue. La société Thé Tip Top se réserve le droit de demander la fourniture
|
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é.
|
d'un justificatif d'identité pour vérifier l'âge déclaré.
|
||||||
</p>
|
</p>
|
||||||
<p><strong>Conditions géographiques —</strong> Le jeu-concours « 100% Gagnant » est
|
<p><strong>Conditions géographiques —</strong> La participation au jeu-concours
|
||||||
ouvert exclusivement aux résidents de France métropolitaine et des Départements et
|
« 100% Gagnant » est réservée aux personnes majeures résidant en France
|
||||||
Régions d'Outre-Mer (DROM). Sont exclus les ressortissants ou résidents de toute
|
métropolitaine. Seuls les tickets de caisse ou factures d'un montant supérieur à
|
||||||
autre zone géographique.
|
49 € émis dans la boutique Thé Tip Top de Nice, à l'occasion de son ouverture,
|
||||||
|
donnent droit à un code de participation.
|
||||||
</p>
|
</p>
|
||||||
<p><strong>Personnes exclues —</strong> Sont exclus de toute participation les membres
|
<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
|
du personnel de la société Thé Tip Top et de l'agence Furious Ducks, leurs
|
||||||
@@ -77,16 +96,16 @@ export default function Cgu() {
|
|||||||
<h3 className="font-semibold text-gray-800 mb-1">3.1 – Services proposés</h3>
|
<h3 className="font-semibold text-gray-800 mb-1">3.1 – Services proposés</h3>
|
||||||
<p>
|
<p>
|
||||||
Le site du jeu-concours « 100% Gagnant » est un site internet dédié permettant
|
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 à
|
aux clients de la société Thé Tip Top de participer à un jeu-concours réservé à
|
||||||
l'occasion de l'ouverture de la 10ᵉ boutique de l'enseigne à Nice. Les services
|
la boutique de Nice, à l'occasion de son ouverture. Les services proposés
|
||||||
proposés incluent :
|
incluent :
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-2 mt-2">
|
<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 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 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 €</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 consultation de l'historique des participations et gains obtenus</li>
|
||||||
<li>La réclamation des lots gagnés, en ligne ou en boutique</li>
|
<li>La réclamation des lots gagnés dans la boutique de Nice</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -101,12 +120,19 @@ export default function Cgu() {
|
|||||||
caisse ou sa facture. Le Site valide le code et attribue immédiatement le lot
|
caisse ou sa facture. Le Site valide le code et attribue immédiatement le lot
|
||||||
correspondant selon la répartition préétablie :
|
correspondant selon la répartition préétablie :
|
||||||
</p>
|
</p>
|
||||||
<ul className="list-disc list-inside space-y-1 mt-2">
|
<ul className="space-y-2 mt-2">
|
||||||
<li>60 % des tickets : infuseur à thé</li>
|
{[
|
||||||
<li>20 % des tickets : boîte de 100 g de thé détox ou d'infusion</li>
|
{ pct: '60 %', text: 'infuseur à thé' },
|
||||||
<li>10 % des tickets : boîte de 100 g de thé signature</li>
|
{ pct: '20 %', text: "boîte de 100 g de thé détox ou d'infusion" },
|
||||||
<li>6 % des tickets : coffret découverte d'une valeur de 39 €</li>
|
{ pct: '10 %', text: 'boîte de 100 g de thé signature' },
|
||||||
<li>4 % des tickets : coffret découverte d'une valeur de 69 €</li>
|
{ 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>
|
</ul>
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
Un même utilisateur peut soumettre plusieurs codes distincts. La participation
|
Un même utilisateur peut soumettre plusieurs codes distincts. La participation
|
||||||
@@ -116,8 +142,9 @@ export default function Cgu() {
|
|||||||
Passé ce délai, les lots non réclamés sont définitivement perdus.
|
Passé ce délai, les lots non réclamés sont définitivement perdus.
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2"><strong>Remise des lots —</strong> Les lots peuvent être réclamés
|
<p className="mt-2"><strong>Remise des lots —</strong> Les lots peuvent être réclamés
|
||||||
en boutique, sur présentation du compte utilisateur validé et de la confirmation
|
exclusivement dans la boutique Thé Tip Top de Nice, sur présentation du compte
|
||||||
de gain.
|
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>
|
||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
À l'issue du jeu-concours, un tirage au sort est effectué parmi l'ensemble des
|
À l'issue du jeu-concours, un tirage au sort est effectué parmi l'ensemble des
|
||||||
@@ -207,11 +234,16 @@ export default function Cgu() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-gray-800 mb-1">6.2 – Suppression du compte</h3>
|
<h3 className="font-semibold text-gray-800 mb-1">6.2 – Suppression du compte</h3>
|
||||||
<p>
|
<p>
|
||||||
L'utilisateur peut demander la suppression de son compte à tout moment en
|
La suppression du compte n'est pas disponible en libre-service. L'utilisateur peut
|
||||||
contactant directement l'équipe via contact@thetiptop.fr. La suppression entraîne
|
la demander à tout moment en contactant directement l'équipe via
|
||||||
la perte définitive des participations et gains non encore réclamés. Les données
|
thetiptop.gp4@gmail.com, notamment depuis la rubrique dédiée de son{' '}
|
||||||
personnelles sont supprimées conformément aux dispositions de la Politique de
|
<Link to="/profile#suppression-compte" className="text-[#006B6B] hover:underline font-medium">
|
||||||
Confidentialité, sous réserve des obligations légales de conservation.
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
|
||||||
export default function Confidentialite() {
|
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 (
|
return (
|
||||||
<main className="bg-[#F0F0F0] min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
<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">
|
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
@@ -19,7 +26,7 @@ export default function Confidentialite() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
|
<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
|
Le responsable du traitement des données collectées via le site du jeu-concours
|
||||||
« 100% Gagnant » est la société Thé Tip Top, située au 18 rue Léon Frot, 75011 Paris.
|
« 100% Gagnant » est la société Thé Tip Top.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -89,7 +96,7 @@ export default function Confidentialite() {
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-700 text-sm sm:text-base">
|
<p className="text-gray-700 text-sm sm:text-base">
|
||||||
Pour toute question concernant vos données personnelles :<br />
|
Pour toute question concernant vos données personnelles :<br />
|
||||||
<strong>Email :</strong> contact@thetiptop.fr
|
<strong>Email :</strong> thetiptop.gp4@gmail.com
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
import { Link, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { loginApi } from '../services/auth'
|
import { loginApi } from '../services/auth'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
import Captcha from '../components/Captcha'
|
||||||
|
import { CAPTCHA_ENABLED } from '../config/captcha'
|
||||||
|
import { homeFor } from '../config/roles'
|
||||||
|
|
||||||
export default function Connexion() {
|
export default function Connexion() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Connexion | Thé Tip Top',
|
||||||
|
description: "Connectez-vous à votre espace participant Thé Tip Top pour saisir votre code et suivre vos gains au jeu-concours 100 % Gagnant.",
|
||||||
|
})
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
@@ -12,29 +21,34 @@ export default function Connexion() {
|
|||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [captchaToken, setCaptchaToken] = useState('')
|
||||||
|
const captchaRef = useRef()
|
||||||
|
|
||||||
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||||
|
|
||||||
// Navigation pleine page (et non fetch) : le flux OAuth passe par des
|
// Navigation pleine page (et non fetch) : le flux OAuth passe par des
|
||||||
// redirections vers Google puis retour sur /auth/callback.
|
// Navigation pleine page (et non fetch) : le flux OAuth passe par des
|
||||||
const API_URL = import.meta.env.VITE_API_URL || ''
|
// redirections vers Google/Facebook puis retour sur /auth/callback.
|
||||||
|
const handleOAuthLogin = (provider) => {
|
||||||
const handleGoogleLogin = () => {
|
const apiUrl = import.meta.env.VITE_API_URL || ''
|
||||||
window.location.href = `${API_URL}/api/auth/google`
|
window.location.href = `${apiUrl}/api/auth/${provider}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const { token, user } = await loginApi(form)
|
const { token, user } = await loginApi({ ...form, captchaToken })
|
||||||
login(user, token)
|
login(user, token)
|
||||||
if (user.role === 'ADMIN') navigate('/admin')
|
// Un employé qui se connecte avec son mot de passe provisoire est
|
||||||
else if (user.role === 'EMPLOYEE') navigate('/employe')
|
// envoyé directement sur le choix de son nouveau mot de passe.
|
||||||
else navigate('/mes-gains')
|
navigate(homeFor(user))
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
|
// Le jeton a été consommé par le backend : on en redemande un.
|
||||||
|
captchaRef.current?.reset()
|
||||||
|
setCaptchaToken('')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -107,15 +121,21 @@ const handleGoogleLogin = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right mt-1">
|
<div className="text-right mt-1">
|
||||||
<button type="button" className="text-xs text-gray-500 hover:text-[#006B6B] hover:underline">
|
<Link
|
||||||
|
to="/mot-de-passe-oublie"
|
||||||
|
state={{ email: form.email }}
|
||||||
|
className="text-xs text-gray-500 hover:text-[#006B6B] hover:underline"
|
||||||
|
>
|
||||||
Mot de passe oublié ?
|
Mot de passe oublié ?
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading || (CAPTCHA_ENABLED && !captchaToken)}
|
||||||
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
{loading ? 'Connexion...' : 'Se connecter'}
|
{loading ? 'Connexion...' : 'Se connecter'}
|
||||||
@@ -131,7 +151,7 @@ const handleGoogleLogin = () => {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleGoogleLogin}
|
onClick={() => handleOAuthLogin('google')}
|
||||||
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
||||||
@@ -144,9 +164,8 @@ const handleGoogleLogin = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled
|
onClick={() => handleOAuthLogin('facebook')}
|
||||||
title="Connexion Facebook bientôt disponible"
|
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
|
||||||
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-500 cursor-not-allowed opacity-60"
|
|
||||||
>
|
>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
||||||
|
|||||||
@@ -1,37 +1,75 @@
|
|||||||
import imgContact from '../assets/img/img-contact.jpg'
|
import imgContact from '../assets/img/img-contact.webp'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
|
||||||
export default function Contact() {
|
export default function Contact() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Contact | Thé Tip Top',
|
||||||
|
description:
|
||||||
|
"Une question sur le jeu-concours, votre participation ou votre lot ? Contactez l'équipe Thé Tip Top par email, téléphone ou via le formulaire en ligne.",
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-[#F0F0F0] px-4 sm:px-6 py-10 sm:py-16">
|
<main className="bg-[#F0F0F0] px-4 sm:px-6 py-10 sm:py-16">
|
||||||
<div className="max-w-6xl mx-auto grid md:grid-cols-2 gap-6 sm:gap-8">
|
<div className="max-w-6xl mx-auto">
|
||||||
|
|
||||||
|
{/* Informations + image */}
|
||||||
|
<div className="grid md:grid-cols-2 gap-6 sm:gap-8 mb-8">
|
||||||
|
|
||||||
{/* Informations */}
|
{/* Informations */}
|
||||||
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
Contact
|
Contact
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-gray-600 leading-relaxed mb-6 sm:mb-8 text-sm sm:text-base">
|
<p className="text-gray-600 leading-relaxed mb-6 sm:mb-8 text-sm sm:text-base">
|
||||||
Une question concernant le jeu-concours, votre participation ou votre lot ?
|
Une question concernant le jeu-concours, votre participation ou votre lot ?
|
||||||
Notre équipe ThéTipTop est à votre écoute.
|
Notre équipe Thé Tip Top est à votre écoute.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="space-y-5 text-gray-700">
|
<div className="space-y-5 text-gray-700">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold text-[#006B6B] mb-1">Adresse</h2>
|
<h2 className="font-semibold text-[#006B6B] mb-1">
|
||||||
<p className="text-sm sm:text-base">ThéTipTop<br />18 rue Léon Frot<br />75011 Paris, France</p>
|
Adresse
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm sm:text-base">
|
||||||
|
Thé Tip Top
|
||||||
|
<br />
|
||||||
|
18 rue Léon Frot
|
||||||
|
<br />
|
||||||
|
75011 Paris, France
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold text-[#006B6B] mb-1">Email</h2>
|
<h2 className="font-semibold text-[#006B6B] mb-1">
|
||||||
<a href="mailto:contact@thetiptop.fr" className="text-sm sm:text-base hover:text-[#006B6B]">contact@thetiptop.fr</a>
|
Email
|
||||||
|
</h2>
|
||||||
|
<a
|
||||||
|
href="mailto:contact@thetiptop.fr"
|
||||||
|
className="text-sm sm:text-base hover:text-[#006B6B]"
|
||||||
|
>
|
||||||
|
contact@thetiptop.fr
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-semibold text-[#006B6B] mb-1">Téléphone</h2>
|
<h2 className="font-semibold text-[#006B6B] mb-1">
|
||||||
<a href="tel:+33184802034" className="text-sm sm:text-base hover:text-[#006B6B]">01 84 80 20 34</a>
|
Téléphone
|
||||||
|
</h2>
|
||||||
|
<a
|
||||||
|
href="tel:+33184802034"
|
||||||
|
className="text-sm sm:text-base hover:text-[#006B6B]"
|
||||||
|
>
|
||||||
|
01 84 80 20 34
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Image */}
|
{/* Image */}
|
||||||
<section className="rounded-2xl shadow overflow-hidden h-64 sm:h-80 md:h-[468px] md:self-start">
|
<section className="rounded-2xl shadow overflow-hidden h-64 sm:h-80 md:h-full md:min-h-0">
|
||||||
<img
|
<img
|
||||||
src={imgContact}
|
src={imgContact}
|
||||||
alt="Une personne savourant un thé"
|
alt="Une personne savourant un thé"
|
||||||
@@ -40,6 +78,90 @@ export default function Contact() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Formulaire */}
|
||||||
|
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10 max-w-4xl mx-auto">
|
||||||
|
<h2 className="text-xl sm:text-2xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Envoyez-nous un message
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form className="space-y-4 sm:space-y-5">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-name"
|
||||||
|
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Nom
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="contact-name"
|
||||||
|
placeholder="Votre nom"
|
||||||
|
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-email"
|
||||||
|
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="contact-email"
|
||||||
|
placeholder="Votre email"
|
||||||
|
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-subject"
|
||||||
|
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Sujet
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="contact-subject"
|
||||||
|
placeholder="Sujet de votre demande"
|
||||||
|
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="contact-message"
|
||||||
|
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Message
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
id="contact-message"
|
||||||
|
rows="4"
|
||||||
|
placeholder="Votre message..."
|
||||||
|
className="w-full border border-gray-300 rounded-xl px-4 py-2.5 sm:py-3 text-sm sm:text-base focus:outline-none focus:ring-2 focus:ring-[#006B6B] resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005858] text-white font-semibold py-2.5 sm:py-3 rounded-xl transition text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Envoyer le message
|
||||||
|
</button>
|
||||||
|
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { resetPasswordApi } from '../services/auth'
|
import { resetPasswordApi } from '../services/auth'
|
||||||
|
import PasswordRules from '../components/PasswordRules'
|
||||||
|
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
|
||||||
|
|
||||||
// Sert les deux parcours qui passent par un jeton envoyé par email :
|
// Sert les deux parcours qui passent par un jeton envoyé par email :
|
||||||
// la définition initiale du mot de passe après une inscription Google,
|
// la définition initiale du mot de passe après une inscription Google,
|
||||||
@@ -21,8 +23,8 @@ export default function DefinirMotDePasse() {
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
if (form.password.length < 8) {
|
if (!isPasswordValid(form.password)) {
|
||||||
setError('Le mot de passe doit contenir au moins 8 caractères.')
|
setError(PASSWORD_RULES_ERROR)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (form.password !== form.confirmPassword) {
|
if (form.password !== form.confirmPassword) {
|
||||||
@@ -58,15 +60,22 @@ export default function DefinirMotDePasse() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Mot de passe</h1>
|
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Nouveau mot de passe</h1>
|
||||||
|
{/* Texte neutre : la page sert au mot de passe oublié comme au
|
||||||
|
premier mot de passe d'un compte Google. */}
|
||||||
<p className="text-gray-500 mt-1 text-sm">
|
<p className="text-gray-500 mt-1 text-sm">
|
||||||
Choisissez un mot de passe pour accéder à votre compte sans passer par Google.
|
Choisissez le mot de passe que vous utiliserez pour vous connecter avec votre adresse email.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm" role="alert">
|
||||||
{error}
|
{error}
|
||||||
|
{/invalide ou expiré/i.test(error) && (
|
||||||
|
<Link to="/mot-de-passe-oublie" className="block mt-2 font-medium text-[#006B6B] hover:underline">
|
||||||
|
Demander un nouveau lien
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -82,10 +91,12 @@ export default function DefinirMotDePasse() {
|
|||||||
value={form.password}
|
value={form.password}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
minLength={8}
|
autoComplete="new-password"
|
||||||
placeholder="8 caractères minimum"
|
aria-describedby="password-rules"
|
||||||
|
placeholder="Votre nouveau mot de passe"
|
||||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
/>
|
/>
|
||||||
|
<PasswordRules id="password-rules" password={form.password} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -2,15 +2,37 @@ import { useState, useRef } from 'react'
|
|||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { registerApi, verifyCodeApi } from '../services/auth'
|
import { registerApi, verifyCodeApi } from '../services/auth'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
import Captcha from '../components/Captcha'
|
||||||
|
import { CAPTCHA_ENABLED } from '../config/captcha'
|
||||||
|
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
|
||||||
|
import PasswordRules from '../components/PasswordRules'
|
||||||
|
|
||||||
// Une naissance ne peut pas être dans le futur : borne haute du sélecteur.
|
// Une naissance ne peut pas être dans le futur : borne haute du sélecteur.
|
||||||
const AUJOURD_HUI = new Date().toISOString().split('T')[0]
|
const AUJOURD_HUI = new Date().toISOString().split('T')[0]
|
||||||
|
|
||||||
|
// Le jeu est réservé aux personnes majeures.
|
||||||
|
function hasAtLeast18Years(birthdate) {
|
||||||
|
const birth = new Date(birthdate)
|
||||||
|
const today = new Date()
|
||||||
|
let age = today.getFullYear() - birth.getFullYear()
|
||||||
|
const monthDiff = today.getMonth() - birth.getMonth()
|
||||||
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) {
|
||||||
|
age--
|
||||||
|
}
|
||||||
|
return age >= 18
|
||||||
|
}
|
||||||
|
|
||||||
// Valeurs alignées sur le tableau de bord admin, qui compte exactement
|
// Valeurs alignées sur le tableau de bord admin, qui compte exactement
|
||||||
// "Homme" et "Femme" ; toute autre valeur retombe dans la catégorie "autre".
|
// "Homme" et "Femme" ; toute autre valeur retombe dans la catégorie "autre".
|
||||||
const GENRES = ['Femme', 'Homme', 'Autre']
|
const GENRES = ['Femme', 'Homme', 'Autre']
|
||||||
|
|
||||||
export default function Inscription() {
|
export default function Inscription() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Créer un compte | Thé Tip Top',
|
||||||
|
description: "Inscrivez-vous en quelques instants pour participer au jeu-concours 100 % Gagnant Thé Tip Top et saisir le code de votre ticket de caisse.",
|
||||||
|
})
|
||||||
|
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
|
|
||||||
@@ -22,6 +44,7 @@ export default function Inscription() {
|
|||||||
lastName: '',
|
lastName: '',
|
||||||
firstName: '',
|
firstName: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
confirmPassword: '',
|
||||||
birthdate: '',
|
birthdate: '',
|
||||||
gender: '',
|
gender: '',
|
||||||
})
|
})
|
||||||
@@ -32,6 +55,8 @@ export default function Inscription() {
|
|||||||
const [showPassword, setShowPassword] = useState(false)
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [captchaToken, setCaptchaToken] = useState('')
|
||||||
|
const captchaRef = useRef()
|
||||||
|
|
||||||
// Code à 4 chiffres
|
// Code à 4 chiffres
|
||||||
const [digits, setDigits] = useState(['', '', '', ''])
|
const [digits, setDigits] = useState(['', '', '', ''])
|
||||||
@@ -41,15 +66,35 @@ export default function Inscription() {
|
|||||||
|
|
||||||
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||||
|
|
||||||
// Même route que la connexion : Google crée le compte à la première venue.
|
// Signalé dès que la confirmation est saisie, sans attendre l'envoi.
|
||||||
const handleGoogleSignup = () => {
|
const passwordMismatch = form.confirmPassword !== '' && form.password !== form.confirmPassword
|
||||||
window.location.href = '/api/auth/google'
|
|
||||||
|
// Même route que la connexion : le provider crée le compte à la première venue.
|
||||||
|
const handleOAuthSignup = (provider) => {
|
||||||
|
const apiUrl = import.meta.env.VITE_API_URL || ''
|
||||||
|
window.location.href = `${apiUrl}/api/auth/${provider}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Étape 1 — Inscription
|
// Étape 1 — Inscription
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
|
|
||||||
|
if (!hasAtLeast18Years(form.birthdate)) {
|
||||||
|
setError('Vous devez avoir au moins 18 ans pour vous inscrire.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isPasswordValid(form.password)) {
|
||||||
|
setError(PASSWORD_RULES_ERROR)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.password !== form.confirmPassword) {
|
||||||
|
setError('Les deux mots de passe ne correspondent pas.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await registerApi({
|
await registerApi({
|
||||||
@@ -57,14 +102,19 @@ export default function Inscription() {
|
|||||||
firstname: form.firstName,
|
firstname: form.firstName,
|
||||||
lastname: form.lastName,
|
lastname: form.lastName,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
|
confirmPassword: form.confirmPassword,
|
||||||
birthdate: form.birthdate,
|
birthdate: form.birthdate,
|
||||||
gender: form.gender,
|
gender: form.gender,
|
||||||
marketingOptIn,
|
marketingOptIn,
|
||||||
|
captchaToken,
|
||||||
})
|
})
|
||||||
setEmail(form.email)
|
setEmail(form.email)
|
||||||
setStep('verify')
|
setStep('verify')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message)
|
setError(err.message)
|
||||||
|
// Le jeton a été consommé par le backend : on en redemande un.
|
||||||
|
captchaRef.current?.reset()
|
||||||
|
setCaptchaToken('')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -158,7 +208,7 @@ export default function Inscription() {
|
|||||||
Le code expire dans 15 minutes.{' '}
|
Le code expire dans 15 minutes.{' '}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError('') }}
|
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError(''); setCaptchaToken('') }}
|
||||||
className="text-[#006B6B] hover:underline"
|
className="text-[#006B6B] hover:underline"
|
||||||
>
|
>
|
||||||
Recommencer
|
Recommencer
|
||||||
@@ -202,9 +252,10 @@ export default function Inscription() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="reg-birthdate" className="block text-sm font-medium text-gray-800 mb-1">Date de naissance</label>
|
<label htmlFor="reg-birthdate" className="block text-sm font-medium text-gray-800 mb-1">Date de naissance <span className="text-gray-800">*</span></label>
|
||||||
<input type="date" id="reg-birthdate" name="birthdate" value={form.birthdate} onChange={handleChange} max={AUJOURD_HUI}
|
<input type="date" id="reg-birthdate" name="birthdate" value={form.birthdate} onChange={handleChange} required max={AUJOURD_HUI}
|
||||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||||
|
<p className="text-xs text-gray-500 mt-1">Le jeu-concours est réservé aux personnes majeures.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -222,6 +273,7 @@ export default function Inscription() {
|
|||||||
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
|
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input type={showPassword ? 'text' : 'password'} id="reg-password" name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
|
<input type={showPassword ? 'text' : 'password'} id="reg-password" name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
|
||||||
|
autoComplete="new-password" aria-describedby="reg-password-rules"
|
||||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||||
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
<button type="button" onClick={() => setShowPassword(!showPassword)}
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600" aria-label="Afficher/masquer">
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600" aria-label="Afficher/masquer">
|
||||||
@@ -230,6 +282,17 @@ export default function Inscription() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<PasswordRules id="reg-password-rules" password={form.password} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reg-confirm-password" className="block text-sm font-medium text-gray-800 mb-1">Confirmer le mot de passe <span className="text-gray-800">*</span></label>
|
||||||
|
<input type={showPassword ? 'text' : 'password'} id="reg-confirm-password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required placeholder="Saisissez-le à nouveau"
|
||||||
|
autoComplete="new-password" aria-invalid={passwordMismatch} aria-describedby={passwordMismatch ? 'reg-confirm-password-error' : undefined}
|
||||||
|
className={`w-full border rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 ${passwordMismatch ? 'border-red-300 focus:ring-red-400 focus:border-red-400' : 'border-gray-200 focus:ring-[#006B6B] focus:border-[#006B6B]'}`} />
|
||||||
|
{passwordMismatch && (
|
||||||
|
<p id="reg-confirm-password-error" className="text-xs text-red-600 mt-1">Les deux mots de passe ne correspondent pas.</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
@@ -255,7 +318,9 @@ export default function Inscription() {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit" disabled={loading || !acceptedTerms}
|
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
|
||||||
|
|
||||||
|
<button type="submit" disabled={loading || !acceptedTerms || (CAPTCHA_ENABLED && !captchaToken)}
|
||||||
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors">
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors">
|
||||||
{loading ? 'Inscription…' : "S'inscrire"}
|
{loading ? 'Inscription…' : "S'inscrire"}
|
||||||
</button>
|
</button>
|
||||||
@@ -268,7 +333,7 @@ export default function Inscription() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<button type="button" onClick={handleGoogleSignup} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
<button type="button" onClick={() => handleOAuthSignup('google')} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
||||||
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||||
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
|
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
|
||||||
@@ -277,7 +342,7 @@ export default function Inscription() {
|
|||||||
</svg>
|
</svg>
|
||||||
Google
|
Google
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled title="Inscription Facebook bientôt disponible" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-500 cursor-not-allowed opacity-60">
|
<button type="button" onClick={() => handleOAuthSignup('facebook')} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
||||||
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />
|
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
|
||||||
export default function MentionsLegales() {
|
export default function MentionsLegales() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Mentions légales | Thé Tip Top',
|
||||||
|
description: "Mentions légales du site Thé Tip Top : éditeur, hébergeur, propriété intellectuelle et informations relatives au jeu-concours 100 % Gagnant.",
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
|
<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">
|
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
@@ -36,12 +43,12 @@ export default function MentionsLegales() {
|
|||||||
<p><strong>Directeur de la publication :</strong> M. Eric Bourdon, en sa qualité de Gérant</p>
|
<p><strong>Directeur de la publication :</strong> M. Eric Bourdon, en sa qualité de Gérant</p>
|
||||||
<p><strong>SIRET :</strong> 362 521 879 00054</p>
|
<p><strong>SIRET :</strong> 362 521 879 00054</p>
|
||||||
<p><strong>TVA intracommunautaire :</strong> FR83362521879</p>
|
<p><strong>TVA intracommunautaire :</strong> FR83362521879</p>
|
||||||
<p><strong>Email :</strong> contact@thetiptop.fr</p>
|
<p><strong>Email :</strong> thetiptop.gp4@gmail.com</p>
|
||||||
<p><strong>Téléphone :</strong> 01 84 80 20 34</p>
|
<p><strong>Téléphone :</strong> 01 84 80 20 34</p>
|
||||||
<p className="pt-2">
|
<p className="pt-2">
|
||||||
Le règlement du jeu-concours a été déposé auprès de Maître Arnaud Rick,
|
Le règlement du jeu-concours a été déposé auprès de Maître Arnaud Rick,
|
||||||
Commissaire de Justice. Il est consultable gratuitement sur simple demande
|
Commissaire de Justice. Il est consultable gratuitement sur simple demande
|
||||||
à l'adresse contact@thetiptop.fr.
|
à l'adresse thetiptop.gp4@gmail.com.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ export default function MesGains() {
|
|||||||
}, [handleAuthError])
|
}, [handleAuthError])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
|
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-xl mx-auto">
|
||||||
<UserNav active="gains" />
|
<UserNav active="gains" />
|
||||||
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|||||||
117
frontend/src/pages/MotDePasseOublie.jsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { Link, useLocation } from 'react-router-dom'
|
||||||
|
import { forgotPasswordApi } from '../services/auth'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
import Captcha from '../components/Captcha'
|
||||||
|
import { CAPTCHA_ENABLED } from '../config/captcha'
|
||||||
|
|
||||||
|
// Demande d'un lien de réinitialisation. La réponse est volontairement la même
|
||||||
|
// que le compte existe ou non, pour ne pas révéler quels emails sont inscrits.
|
||||||
|
export default function MotDePasseOublie() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Mot de passe oublié | Thé Tip Top',
|
||||||
|
description: 'Recevez par email un lien pour choisir un nouveau mot de passe pour votre compte Thé Tip Top.',
|
||||||
|
})
|
||||||
|
|
||||||
|
// L'email déjà saisi sur la page de connexion est repris.
|
||||||
|
const { state } = useLocation()
|
||||||
|
const [email, setEmail] = useState(state?.email ?? '')
|
||||||
|
const [sent, setSent] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [captchaToken, setCaptchaToken] = useState('')
|
||||||
|
const captchaRef = useRef()
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const data = await forgotPasswordApi({ email, captchaToken })
|
||||||
|
setSent(data.message)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
// Le jeton a été consommé par le backend : on en redemande un.
|
||||||
|
captchaRef.current?.reset()
|
||||||
|
setCaptchaToken('')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sent) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto text-center">
|
||||||
|
<h1 className="text-3xl font-bold italic font-serif text-[#006B6B] mb-4">Vérifiez vos emails</h1>
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm" role="status">
|
||||||
|
{sent}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Le lien est valable 30 minutes. Pensez à regarder dans vos courriers indésirables.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setSent(''); setCaptchaToken('') }}
|
||||||
|
className="text-sm text-gray-500 hover:text-[#006B6B] hover:underline mb-3"
|
||||||
|
>
|
||||||
|
Je n'ai rien reçu, renvoyer un lien
|
||||||
|
</button>
|
||||||
|
<Link to="/connexion" className="text-sm text-[#006B6B] font-medium hover:underline">
|
||||||
|
Retour à la connexion
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Mot de passe oublié</h1>
|
||||||
|
<p className="text-gray-500 mt-2 text-sm">
|
||||||
|
Saisissez l'adresse email de votre compte : nous vous enverrons un lien pour choisir un nouveau mot de passe.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="forgot-email" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
|
Adresse email <span className="text-gray-800">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="forgot-email"
|
||||||
|
name="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="Votre email"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading || (CAPTCHA_ENABLED && !captchaToken)}
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Envoi…' : 'Recevoir le lien'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-8">
|
||||||
|
<Link to="/connexion" className="text-gray-700 font-medium hover:underline">
|
||||||
|
Retour à la connexion
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
145
frontend/src/pages/NotreConcept.jsx
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
import bannerImg from '../assets/img/banner-the-tip-top.webp'
|
||||||
|
|
||||||
|
export default function NotreConcept() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Notre concept : un jeu-concours sans perdant | Thé Tip Top',
|
||||||
|
description: "Pourquoi un jeu 100 % gagnant ? Découvrez le concept, la répartition des lots et les garanties de loyauté du jeu-concours Thé Tip Top.",
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-50 min-h-screen">
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="max-w-7xl mx-auto px-4 sm:px-6 py-12 sm:py-16 md:py-20">
|
||||||
|
<div className="text-center max-w-3xl mx-auto">
|
||||||
|
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Le concept 100 % Gagnant : un jeu-concours sans perdant
|
||||||
|
</h1>
|
||||||
|
<p className="text-base sm:text-xl text-gray-600 mb-8 sm:mb-10">
|
||||||
|
La plupart des jeux-concours font gagner une personne et laissent les autres repartir
|
||||||
|
les mains vides. Nous avons choisi l'inverse : chaque code distribué correspond à
|
||||||
|
un lot réel, attribué à l'avance. Personne ne joue pour rien.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
|
||||||
|
<Link
|
||||||
|
to="/accueil"
|
||||||
|
className="bg-[#006B6B] hover:bg-[#005555] text-white font-semibold px-6 sm:px-8 py-3 rounded-xl transition-colors text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Voir le jeu-concours
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/regles-jeu"
|
||||||
|
className="border border-[#006B6B] text-[#006B6B] hover:bg-[#006B6B] hover:text-white font-semibold px-6 sm:px-8 py-3 rounded-xl transition-colors text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Consulter le règlement
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Visuel principal */}
|
||||||
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
|
||||||
|
<div className="rounded-2xl shadow overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={bannerImg}
|
||||||
|
alt="Infuseur à thé Thé Tip Top, lot garanti du jeu-concours 100 % Gagnant"
|
||||||
|
className="w-full h-full object-cover object-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Pourquoi un jeu sans perdant */}
|
||||||
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
|
||||||
|
<div className="bg-white rounded-2xl shadow p-6 sm:p-10">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Pourquoi un jeu sans perdant
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 text-sm sm:text-base leading-relaxed">
|
||||||
|
Un jeu classique récompense la chance. Celui-ci récompense la visite. Nous ouvrons une
|
||||||
|
nouvelle adresse et nous voulions que chaque personne qui pousse la porte reparte avec
|
||||||
|
quelque chose à goûter, pas avec un ticket de loterie. C'est aussi la raison pour
|
||||||
|
laquelle le lot se retire sur place : le jeu est un prétexte à une rencontre, pas à
|
||||||
|
une transaction de plus.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Comment la loyauté du jeu est garantie */}
|
||||||
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
|
||||||
|
<div className="bg-white rounded-2xl shadow p-6 sm:p-10">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Comment la loyauté du jeu est garantie
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 text-sm sm:text-base leading-relaxed">
|
||||||
|
L'intégralité des codes et leur lot associé sont générés avant l'ouverture du jeu, puis
|
||||||
|
déposés auprès de Maître Arnaud Rick, commissaire de justice. Aucun lot ne peut être
|
||||||
|
modifié en cours d'opération. Le règlement complet est{' '}
|
||||||
|
<a
|
||||||
|
href="/reglement-jeu-concours-100-gagnant.pdf"
|
||||||
|
className="text-[#006B6B] font-semibold underline hover:no-underline"
|
||||||
|
>
|
||||||
|
consultable et téléchargeable
|
||||||
|
</a>{' '}
|
||||||
|
à tout moment. Le tirage au sort du lot principal est effectué sous le contrôle du même
|
||||||
|
commissaire de justice, et le nombre de participations n'augmente pas les chances de le
|
||||||
|
remporter.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* La répartition des lots */}
|
||||||
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-16">
|
||||||
|
<div className="bg-white rounded-2xl shadow p-6 sm:p-10">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
La répartition des lots, annoncée à l'avance
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 text-sm sm:text-base leading-relaxed">
|
||||||
|
Six codes sur dix donnent un infuseur, deux sur dix une boîte de 100 g, un sur dix une
|
||||||
|
boîte d'un mélange signature, et le solde un coffret découverte. Cette répartition est
|
||||||
|
publiée dès le premier jour et ne varie pas. Vous savez donc, avant même de jouer, ce
|
||||||
|
que vous pouvez obtenir et dans quelle proportion.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Un jeu conçu pour ne rien gaspiller */}
|
||||||
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-20">
|
||||||
|
<div className="bg-white rounded-2xl shadow p-6 sm:p-10">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
|
Un jeu conçu pour ne rien gaspiller
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 text-sm sm:text-base leading-relaxed">
|
||||||
|
Le lot le plus distribué est un infuseur réutilisable, choisi pour remplacer durablement
|
||||||
|
les sachets jetables plutôt que pour faire nombre. La participation est entièrement
|
||||||
|
numérique, sans bulletin papier, et nos supports imprimés se limitent au strict
|
||||||
|
nécessaire d'un point de vente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-20">
|
||||||
|
<div className="bg-[#006B6B] rounded-2xl p-6 sm:p-10 text-center text-white">
|
||||||
|
<h2 className="text-2xl sm:text-3xl font-bold mb-3 sm:mb-4">
|
||||||
|
Une question sur le concept du jeu ?
|
||||||
|
</h2>
|
||||||
|
<p className="mb-6 sm:mb-8 text-base sm:text-lg">
|
||||||
|
Notre équipe reste à votre écoute pour tout ce qui concerne le jeu-concours et vos
|
||||||
|
lots.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Link
|
||||||
|
to="/contact"
|
||||||
|
className="bg-white text-[#006B6B] px-6 sm:px-8 py-3 rounded-xl font-semibold hover:bg-[#006B6B] hover:text-white border border-white text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Contactez-nous
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
139
frontend/src/pages/NouveauMotDePasse.jsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Navigate, useNavigate } from 'react-router-dom'
|
||||||
|
import { setInitialPasswordApi } from '../services/auth'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { homeFor } from '../config/roles'
|
||||||
|
import PasswordRules from '../components/PasswordRules'
|
||||||
|
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
|
||||||
|
|
||||||
|
// Première connexion d'un employé créé par un admin : le mot de passe reçu
|
||||||
|
// par email est provisoire, il doit être remplacé avant tout accès.
|
||||||
|
export default function NouveauMotDePasse() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { user, login, logout } = useAuth()
|
||||||
|
|
||||||
|
const [form, setForm] = useState({ password: '', confirmPassword: '' })
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
if (!user) return <Navigate to="/connexion" replace />
|
||||||
|
if (!user.mustChangePassword) return <Navigate to={homeFor(user)} replace />
|
||||||
|
|
||||||
|
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (!isPasswordValid(form.password)) {
|
||||||
|
setError(PASSWORD_RULES_ERROR)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (form.password !== form.confirmPassword) {
|
||||||
|
setError('Les deux mots de passe ne correspondent pas.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
await setInitialPasswordApi(form)
|
||||||
|
const updatedUser = { ...user, mustChangePassword: false }
|
||||||
|
login(updatedUser, localStorage.getItem('token'))
|
||||||
|
navigate(homeFor(updatedUser), { replace: true })
|
||||||
|
} catch (err) {
|
||||||
|
if (err.status === 401) {
|
||||||
|
logout()
|
||||||
|
navigate('/connexion', { replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(err.message)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
logout()
|
||||||
|
navigate('/connexion', { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Nouveau mot de passe</h1>
|
||||||
|
<p className="text-gray-500 mt-2 text-sm">
|
||||||
|
Bienvenue {user.firstname} ! Vous vous êtes connecté avec un mot de passe provisoire.
|
||||||
|
Choisissez votre propre mot de passe pour accéder à votre espace.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="new-password" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
|
Nouveau mot de passe <span className="text-gray-800">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="new-password"
|
||||||
|
name="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
aria-describedby="new-password-rules"
|
||||||
|
placeholder="Votre nouveau mot de passe"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
|
<PasswordRules id="new-password-rules" password={form.password} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirm-new-password" className="block text-sm font-medium text-gray-800 mb-1">
|
||||||
|
Confirmer le mot de passe <span className="text-gray-800">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
id="confirm-new-password"
|
||||||
|
name="confirmPassword"
|
||||||
|
value={form.confirmPassword}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
autoComplete="new-password"
|
||||||
|
placeholder="Saisissez-le à nouveau"
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showPassword}
|
||||||
|
onChange={(e) => setShowPassword(e.target.checked)}
|
||||||
|
className="w-5 h-5 accent-[#006B6B]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-500">Afficher les mots de passe</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? 'Enregistrement…' : 'Enregistrer mon mot de passe'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-8">
|
||||||
|
<button type="button" onClick={handleLogout} className="text-gray-700 font-medium hover:underline">
|
||||||
|
Se déconnecter
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,12 +1,31 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
import UserNav from '../components/UserNav'
|
import UserNav from '../components/UserNav'
|
||||||
import { changePasswordApi, meApi, forgotPasswordApi } from '../services/auth'
|
import {
|
||||||
|
changePasswordApi,
|
||||||
|
meApi,
|
||||||
|
sendPasswordSetupLinkApi,
|
||||||
|
} from '../services/auth'
|
||||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||||
|
import PasswordRules from '../components/PasswordRules'
|
||||||
|
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
|
||||||
|
|
||||||
export default function Profile() {
|
export default function Profile() {
|
||||||
const user = JSON.parse(localStorage.getItem('user') || '{}')
|
const user = JSON.parse(localStorage.getItem('user') || '{}')
|
||||||
const initials = `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase()
|
const initials = `${(user.firstname || '?')[0]}${(user.lastname || '?')[0]}`.toUpperCase()
|
||||||
const handleAuthError = useHandleAuthError()
|
const handleAuthError = useHandleAuthError()
|
||||||
|
const { hash } = useLocation()
|
||||||
|
|
||||||
|
const deleteSectionRef = useRef(null)
|
||||||
|
|
||||||
|
// Permet à un lien externe (ex. les CGU) de renvoyer directement sur cette
|
||||||
|
// section via /profile#suppression-compte, plutôt que d'obliger l'utilisateur
|
||||||
|
// à chercher l'information au milieu de la page.
|
||||||
|
useEffect(() => {
|
||||||
|
if (hash === '#suppression-compte') {
|
||||||
|
deleteSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
|
}
|
||||||
|
}, [hash])
|
||||||
|
|
||||||
const [pwForm, setPwForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
const [pwForm, setPwForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' })
|
||||||
const [showPw, setShowPw] = useState({ current: false, new: false, confirm: false })
|
const [showPw, setShowPw] = useState({ current: false, new: false, confirm: false })
|
||||||
@@ -34,7 +53,7 @@ export default function Profile() {
|
|||||||
setLinkError('')
|
setLinkError('')
|
||||||
setLinkSending(true)
|
setLinkSending(true)
|
||||||
try {
|
try {
|
||||||
await forgotPasswordApi(user.email)
|
await sendPasswordSetupLinkApi()
|
||||||
setLinkSent(true)
|
setLinkSent(true)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!handleAuthError(e)) setLinkError(e.message)
|
if (!handleAuthError(e)) setLinkError(e.message)
|
||||||
@@ -54,8 +73,8 @@ export default function Profile() {
|
|||||||
setPwError('Les nouveaux mots de passe ne correspondent pas.')
|
setPwError('Les nouveaux mots de passe ne correspondent pas.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (pwForm.newPassword.length < 8) {
|
if (!isPasswordValid(pwForm.newPassword)) {
|
||||||
setPwError('Le nouveau mot de passe doit faire au moins 8 caractères.')
|
setPwError(PASSWORD_RULES_ERROR)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +100,7 @@ export default function Profile() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
|
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-xl mx-auto">
|
||||||
<UserNav active="profile" />
|
<UserNav active="profile" />
|
||||||
|
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
@@ -180,6 +199,8 @@ export default function Profile() {
|
|||||||
value={pwForm[name]}
|
value={pwForm[name]}
|
||||||
onChange={handlePwChange}
|
onChange={handlePwChange}
|
||||||
required
|
required
|
||||||
|
autoComplete={name === 'currentPassword' ? 'current-password' : 'new-password'}
|
||||||
|
aria-describedby={name === 'newPassword' ? 'profile-password-rules' : undefined}
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-11 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-11 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
|
||||||
/>
|
/>
|
||||||
@@ -192,6 +213,9 @@ export default function Profile() {
|
|||||||
<EyeIcon visible={showPw[key]} />
|
<EyeIcon visible={showPw[key]} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{name === 'newPassword' && (
|
||||||
|
<PasswordRules id="profile-password-rules" password={pwForm.newPassword} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -205,6 +229,30 @@ export default function Profile() {
|
|||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Séparateur */}
|
||||||
|
<div className="border-t border-gray-100 my-6" />
|
||||||
|
|
||||||
|
{/* Section suppression du compte */}
|
||||||
|
<div ref={deleteSectionRef} id="suppression-compte">
|
||||||
|
<h2 className="text-sm font-semibold text-gray-800 mb-4">Supprimer mon compte</h2>
|
||||||
|
|
||||||
|
<div className="px-4 py-4 rounded-lg bg-gray-50 border border-gray-200">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Pour des raisons de sécurité, la suppression de compte n'est pas disponible en
|
||||||
|
libre-service. Envoyez-nous un email depuis votre adresse enregistrée
|
||||||
|
({user.email || '—'}) et notre équipe supprimera votre compte, vos participations et
|
||||||
|
vos gains non retirés.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="mailto:thetiptop.gp4@gmail.com?subject=Suppression%20de%20mon%20compte"
|
||||||
|
className="mt-3 inline-flex items-center justify-center bg-[#006B6B] hover:bg-[#005959] text-white font-medium px-4 py-2.5 rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
|
Demander la suppression par email
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useAuth } from '../context/AuthContext'
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
|
||||||
export default function ReglesJeu() {
|
export default function ReglesJeu() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const isLoggedIn = !!user
|
const isLoggedIn = !!user
|
||||||
|
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Règles du jeu-concours 100 % Gagnant | Thé Tip Top',
|
||||||
|
description: "Comment participer au jeu-concours Thé Tip Top : obtenir un code, créer un compte, découvrir votre lot et tenter de remporter le grand gagnant.",
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-gray-50 min-h-screen">
|
<div className="bg-gray-50 min-h-screen">
|
||||||
|
|
||||||
@@ -15,7 +21,7 @@ export default function ReglesJeu() {
|
|||||||
Règles du jeu
|
Règles du jeu
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-base sm:text-xl text-gray-600">
|
<p className="text-base sm:text-xl text-gray-600">
|
||||||
Participez au jeu-concours ThéTipTop et découvrez immédiatement
|
Jeu-concours réservé à notre boutique de Nice : participez et découvrez immédiatement
|
||||||
le lot que vous avez gagné.
|
le lot que vous avez gagné.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,7 +37,7 @@ export default function ReglesJeu() {
|
|||||||
{
|
{
|
||||||
num: '1',
|
num: '1',
|
||||||
title: 'Obtenez un code',
|
title: 'Obtenez un code',
|
||||||
text: "Achetez un produit ThéTipTop d'une valeur supérieure à 49€ en ligne ou en magasin pour recevoir un code unique à 10 chiffres.",
|
text: "Achetez un produit ThéTipTop d'une valeur supérieure à 49€ dans notre boutique de Nice pour recevoir un code unique à 10 chiffres.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
num: '2',
|
num: '2',
|
||||||
@@ -59,17 +65,39 @@ export default function ReglesJeu() {
|
|||||||
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6 text-center">
|
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-4 sm:mb-6 text-center">
|
||||||
Que pouvez-vous gagner ?
|
Que pouvez-vous gagner ?
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 text-base sm:text-lg text-center leading-relaxed mb-6 sm:mb-8">
|
<p className="text-gray-600 text-base sm:text-lg text-center leading-relaxed mb-4 sm:mb-6">
|
||||||
Tous les billets sont gagnants ! Les récompenses peuvent aller
|
Tous les billets sont gagnants ! Selon votre ticket, vous remportez :
|
||||||
d'un infuseur à thé à un coffret découverte d'une valeur de 69€,
|
|
||||||
en passant par des thés détox et des thés signatures.
|
|
||||||
</p>
|
</p>
|
||||||
|
<ul className="text-gray-700 text-sm sm:text-base leading-relaxed space-y-2 max-w-md mx-auto mb-6 sm:mb-8">
|
||||||
|
{[
|
||||||
|
{ pct: '60 %', text: 'un infuseur à thé' },
|
||||||
|
{ pct: '20 %', text: "une boîte de 100 g de thé détox ou d'infusion" },
|
||||||
|
{ pct: '10 %', text: 'une boîte de 100 g de thé signature' },
|
||||||
|
{ pct: '6 %', text: "un coffret découverte d'une valeur de 39 €" },
|
||||||
|
{ pct: '4 %', text: "un 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>
|
||||||
<div className="bg-gray-100 rounded-xl p-4 sm:p-6 text-center">
|
<div className="bg-gray-100 rounded-xl p-4 sm:p-6 text-center">
|
||||||
<h3 className="text-xl sm:text-2xl font-bold text-[#006B6B] mb-2">Grand gagnant</h3>
|
<h3 className="text-xl sm:text-2xl font-bold text-[#006B6B] mb-2">Grand gagnant</h3>
|
||||||
<p className="text-gray-700 text-sm sm:text-base">
|
<p className="text-gray-700 text-sm sm:text-base">
|
||||||
Le participant tiré au sort remportera une année de thé d'une valeur de 360€ !
|
Un tirage au sort, effectué sous le contrôle de Maître Arnaud Rick, Commissaire de
|
||||||
|
Justice, désignera le gagnant d'une année de thé d'une valeur de 360€ !
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mt-6 sm:mt-8">
|
||||||
|
<a
|
||||||
|
href="/reglement-jeu-concours-100-gagnant.pdf"
|
||||||
|
className="inline-flex items-center justify-center gap-2 border border-[#006B6B] text-[#006B6B] hover:bg-[#006B6B] hover:text-white font-semibold px-6 py-3 rounded-xl transition-colors text-sm sm:text-base"
|
||||||
|
>
|
||||||
|
Télécharger le règlement (PDF)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -80,7 +108,7 @@ export default function ReglesJeu() {
|
|||||||
Ne manquez pas votre chance !
|
Ne manquez pas votre chance !
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mb-6 sm:mb-8 text-base sm:text-lg">
|
<p className="mb-6 sm:mb-8 text-base sm:text-lg">
|
||||||
Inscrivez-vous, entrez votre code de ticket et retirez votre lot en magasin ou en ligne.
|
Inscrivez-vous, entrez votre code de ticket et retirez votre lot dans notre boutique de Nice.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
|
<div className="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
|
||||||
{!isLoggedIn && (
|
{!isLoggedIn && (
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export default function SaisirCodeUnique() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-lg mx-auto">
|
<div className="min-h-screen bg-gray-50 flex flex-col px-8 py-12 max-w-xl mx-auto">
|
||||||
<UserNav active="code" />
|
<UserNav active="code" />
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { useAuth} from '../context/AuthContext'
|
import { useAuth} from '../context/AuthContext'
|
||||||
|
import usePageMeta from '../hooks/usePageMeta'
|
||||||
|
|
||||||
const sections = [
|
const sections = [
|
||||||
{
|
{
|
||||||
title: 'Navigation principale',
|
title: 'Navigation principale',
|
||||||
links: [
|
links: [
|
||||||
{ label: 'Accueil', to: '/accueil', desc: "Page d'accueil du jeu-concours" },
|
{ label: 'Accueil', to: '/accueil', desc: "Page d'accueil du jeu-concours" },
|
||||||
|
{ label: 'Notre concept', to: '/notre-concept', desc: 'Pourquoi un jeu-concours sans perdant' },
|
||||||
{ label: 'Règles du jeu', to: '/regles-jeu', desc: 'Comment participer et que gagner' },
|
{ label: 'Règles du jeu', to: '/regles-jeu', desc: 'Comment participer et que gagner' },
|
||||||
{ label: 'Contact', to: '/contact', desc: 'Nous contacter' },
|
{ label: 'Contact', to: '/contact', desc: 'Nous contacter' },
|
||||||
],
|
],
|
||||||
@@ -44,6 +46,11 @@ const sections = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function Sitemap() {
|
export default function Sitemap() {
|
||||||
|
usePageMeta({
|
||||||
|
title: 'Plan du site | Thé Tip Top',
|
||||||
|
description: "Retrouvez toutes les pages du site Thé Tip Top : jeu-concours, compte participant, informations légales et espaces professionnels.",
|
||||||
|
})
|
||||||
|
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const isAdmin = user?.role === 'ADMIN'
|
const isAdmin = user?.role === 'ADMIN'
|
||||||
const visibleSections = sections.filter(
|
const visibleSections = sections.filter(
|
||||||
|
|||||||
@@ -59,3 +59,38 @@ export async function setGameSettingsApi(startDate) {
|
|||||||
if (!res.ok) throw apiError(data.message || 'Erreur configuration du jeu.', res.status)
|
if (!res.ok) throw apiError(data.message || 'Erreur configuration du jeu.', res.status)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function getEmployeesApi() {
|
||||||
|
const res = await fetch(`${BASE}/employees`, { headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors du chargement des employés.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEmployeeDeliveriesApi(employeeId) {
|
||||||
|
const res = await fetch(`${BASE}/employees/${employeeId}/deliveries`, { headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors du chargement des remises.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeliveryByTicketCodeApi(code) {
|
||||||
|
const res = await fetch(`${BASE}/deliveries/${encodeURIComponent(code.trim())}`, { headers: authHeaders() })
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors de la recherche du ticket.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pas de mot de passe : le serveur en génère un provisoire et l'envoie par
|
||||||
|
// email à l'employé.
|
||||||
|
export async function createEmployeeApi({ firstname, lastname, email }) {
|
||||||
|
const res = await fetch(`${BASE}/employees`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: JSON.stringify({ firstname, lastname, email }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || "Erreur lors de la création de l'employé.", res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ function apiError(message, status) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginApi({ email, password }) {
|
export async function loginApi({ email, password, captchaToken }) {
|
||||||
const res = await fetch(`${BASE}/login`, {
|
const res = await fetch(`${BASE}/login`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password, captchaToken: captchaToken || undefined }),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw apiError(data.message || 'Erreur de connexion.', res.status)
|
if (!res.ok) throw apiError(data.message || 'Erreur de connexion.', res.status)
|
||||||
@@ -23,9 +23,11 @@ export async function registerApi({
|
|||||||
firstname,
|
firstname,
|
||||||
lastname,
|
lastname,
|
||||||
password,
|
password,
|
||||||
|
confirmPassword,
|
||||||
birthdate,
|
birthdate,
|
||||||
gender,
|
gender,
|
||||||
marketingOptIn,
|
marketingOptIn,
|
||||||
|
captchaToken,
|
||||||
}) {
|
}) {
|
||||||
const res = await fetch(`${BASE}/register`, {
|
const res = await fetch(`${BASE}/register`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -37,13 +39,21 @@ export async function registerApi({
|
|||||||
firstname,
|
firstname,
|
||||||
lastname,
|
lastname,
|
||||||
password,
|
password,
|
||||||
|
confirmPassword,
|
||||||
birthdate: birthdate || undefined,
|
birthdate: birthdate || undefined,
|
||||||
gender: gender || undefined,
|
gender: gender || undefined,
|
||||||
marketingOptIn: marketingOptIn ?? undefined,
|
marketingOptIn: marketingOptIn ?? undefined,
|
||||||
|
captchaToken: captchaToken || undefined,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw apiError(data.message || "Erreur lors de l'inscription.", res.status)
|
if (!res.ok) {
|
||||||
|
// Le backend renvoie un message générique ("Données invalides") avec le
|
||||||
|
// détail par champ dans `errors` : on préfère afficher ce détail quand il
|
||||||
|
// existe (ex. l'âge minimum requis pour la date de naissance).
|
||||||
|
const fieldMessage = Object.values(data.errors || {})[0]?.[0]
|
||||||
|
throw apiError(fieldMessage || data.message || "Erreur lors de l'inscription.", res.status)
|
||||||
|
}
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,11 +68,23 @@ export async function verifyCodeApi({ email, code }) {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function forgotPasswordApi(email) {
|
export async function forgotPasswordApi({ email, captchaToken }) {
|
||||||
const res = await fetch(`${BASE}/forgot-password`, {
|
const res = await fetch(`${BASE}/forgot-password`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email, captchaToken: captchaToken || undefined }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utilisateur connecté : lien envoyé à l'adresse de son compte.
|
||||||
|
export async function sendPasswordSetupLinkApi() {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const res = await fetch(`${BASE}/password-setup-link`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status)
|
if (!res.ok) throw apiError(data.message || "Erreur lors de l'envoi du lien.", res.status)
|
||||||
@@ -101,6 +123,21 @@ export async function deleteAccountApi() {
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setInitialPasswordApi({ password, confirmPassword }) {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
const res = await fetch(`${BASE}/initial-password`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ password, confirmPassword }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || "Erreur lors de l'enregistrement du mot de passe.", res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export async function changePasswordApi({ currentPassword, newPassword }) {
|
export async function changePasswordApi({ currentPassword, newPassword }) {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const res = await fetch(`${BASE}/change-password`, {
|
const res = await fetch(`${BASE}/change-password`, {
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ function apiError(message, status) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public : dates du jeu-concours fixées par l'admin.
|
||||||
|
export async function getGameWindowApi() {
|
||||||
|
const res = await fetch(`${BASE}/window`)
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw apiError(data.message || 'Erreur lors de la lecture des dates du jeu.', res.status)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
export async function myGainsApi() {
|
export async function myGainsApi() {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const res = await fetch(`${BASE}/my-gains`, {
|
const res = await fetch(`${BASE}/my-gains`, {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ import '@testing-library/jest-dom'
|
|||||||
import { afterEach } from 'vitest'
|
import { afterEach } from 'vitest'
|
||||||
import { cleanup } from '@testing-library/react'
|
import { cleanup } from '@testing-library/react'
|
||||||
|
|
||||||
|
// jsdom n'implémente pas le défilement, présent dans tous les navigateurs.
|
||||||
|
Element.prototype.scrollIntoView ??= function scrollIntoView() {}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup()
|
cleanup()
|
||||||
localStorage.clear()
|
localStorage.clear()
|
||||||
|
|||||||