70 Commits

Author SHA1 Message Date
d56b91479f fix: prevent turnstile during prerender
All checks were successful
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.38% (551/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 83
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 80.90% (555/686) * Lines of Code: 686 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-09-20 01:25:06 +00:00
7c1e4cf3e2 fix: load Turnstile site key in frontend build
Some checks failed
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.66% (553/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 83
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 80.85% (553/684) * Lines of Code: 684 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-09-20 01:07:37 +00:00
3a5eed6b96 fix: load Turnstile without onload callback
All checks were successful
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.38% (551/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 83
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 80.85% (553/684) * Lines of Code: 684 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-09-20 00:49:03 +00:00
e8080be405 fix: explicitly load Cloudflare Turnstile
All checks were successful
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.38% (551/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 83
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 80.85% (553/684) * Lines of Code: 684 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-09-20 00:43:04 +00:00
82d3daf20f fix(ci): inject Turnstile site key into frontend build
All checks were successful
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.38% (551/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 83
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 84.94% (547/644) * Lines of Code: 644 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-09-19 23:23:18 +00:00
13584d2b0f fix: improve admin game date form on mobile
All checks were successful
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.38% (551/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 83
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 84.94% (547/644) * Lines of Code: 644 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-09-19 23:02:18 +00:00
9bf038278a fix credentials
All checks were successful
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.66% (553/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 83
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 84.94% (547/644) * Lines of Code: 644 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-09-18 02:39:27 +00:00
10cffd77cb fix: configure frontend prerender and Google OAuth
All checks were successful
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.66% (553/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 83
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 84.94% (547/644) * Lines of Code: 644 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-09-18 02:24:50 +00:00
25fcd9974a test: wait for dynamic accueil metadata
All checks were successful
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.38% (551/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 83
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 84.94% (547/644) * Lines of Code: 644 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-09-18 01:44:52 +00:00
536faaefd5 fix(ci): fix Playwright prerender in frontend build
Some checks failed
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.66% (553/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build src/test/fonctionnel/Accueil.test.jsx.Accueil - dates du jeu-concours > affiche les dates fixées dans le tableau de bord admin failed
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-09-18 01:38:40 +00:00
9edb3255e8 fix(ci): use system Chromium for frontend prerender
Some checks failed
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.66% (553/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 83
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 84.94% (547/644) * Lines of Code: 644 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-09-18 01:32:13 +00:00
b96c0b50ab fix(frontend): use Playwright managed Chromium
Some checks failed
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.38% (551/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 83
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-09-18 01:27:51 +00:00
8dce8b238c test: exclude static assets from coverage
Some checks failed
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.38% (551/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 83
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 84.94% (547/644) * Lines of Code: 644 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-09-18 00:38:27 +00:00
abd14b713e fix: update dynamic page metadata
Some checks failed
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.38% (551/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 83
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 84.94% (547/644) * Lines of Code: 644 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-09-18 00:13:34 +00:00
a7cafd1400 ci: fix chromium path for frontend prerender
Some checks failed
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.38% (551/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build src/test/fonctionnel/Accueil.test.jsx.Accueil - dates du jeu-concours > affiche les dates fixées dans le tableau de bord admin failed
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-09-18 00:04:04 +00:00
6e65c0392c test: increase frontend admin service coverage
Some checks failed
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.38% (551/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build src/test/fonctionnel/Accueil.test.jsx.Accueil - dates du jeu-concours > affiche les dates fixées dans le tableau de bord admin failed
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-09-17 23:46:28 +00:00
e257da41b0 ci: use system Chromium for frontend prerender
Some checks failed
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.38% (551/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 72
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 84.87% (505/595) * Lines of Code: 595 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-09-17 19:26:08 +00:00
89e49bc56d ci: fix frontend chromium installation permissions
Some checks failed
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.38% (551/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 72
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-09-17 19:18:35 +00:00
a0869f6f65 ci: install Chromium for frontend prerender
Some checks failed
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.38% (551/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 72
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-09-17 19:16:30 +00:00
4c2849611f ci: install Chromium for frontend prerender
Some checks failed
Tests / Backend - Tests skipped: 8, passed: 74
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-09-17 19:15:43 +00:00
279a071a15 fix: remove unused forgot password import
Some checks failed
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.66% (553/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 72
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-09-17 19:08:49 +00:00
1450c0b4c9 merge: synchronize develop with github main
Some checks failed
Tests / Backend - Tests skipped: 8, passed: 74
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 78.38% (551/703) * Lines of Code: 703 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-09-17 18:46:47 +00:00
95a264dbae merge: integrate Emmanuel changes into main 2026-09-17 18:36:09 +00:00
Emma781227
2201446ee6 changement 2026-09-14 22:30:40 +02:00
Emma781227
8a6355cde1 ajout de la connection via facebook et du captcha 2026-09-14 21:08:04 +02:00
Alexis SANOGO
28d743deb5 Ajouter SSR prerending, espace employe, formulaire inscription employee depuis admin 2026-09-04 16:08:22 +02:00
Alexis SANOGO
dfa5fb81a3 optimiser SEO et images, corriger age minimum inscription 2026-09-04 12:47:19 +02:00
Alexis SANOGO
a1ab2df975 Ajouter la page concept, améliorer la bannière, ajouter pages contact et concept au menu et ajouter la condition d'inscription pour plus de 18 ans 2026-08-29 01:44:20 +02:00
4c3e6cd384 ci: enforce SonarQube quality gate
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 60.55% (333/550) * Lines of Code: 550 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 86.14% (230/267) * Lines of Code: 267 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-28 09:08:06 +00:00
55fb17634d security: remove hardcoded admin credentials 2026-08-28 09:05:11 +00:00
Alexis SANOGO
fc9bcbf8da ajouter sitemap et corriger SEO(title et description) 2026-08-27 17:23:28 +02:00
Alexis SANOGO
d29d2ea052 Ajouter le fichier PDF deu règlement du jeu 2026-08-27 16:30:00 +02:00
Alexis SANOGO
4f0b6d22f2 Ajouter des textes et fichier pdf pour le règlement du jeu 2026-08-27 16:28:38 +02:00
Alexis SANOGO
b1272bd008 Corriger les textes pour les limiter uniquement à la boutique de Nice 2026-08-27 15:25:53 +02:00
Emma781227
9609a7fcf7 Merge remote-tracking branch 'origin/Alexis' into Emmanuel
Récupère la FAQ de la page d'accueil et la refonte de la page Contact, ainsi
que la chaîne CI/CD et l'infrastructure VPS HTTPS venues de main.

Résolution des 13 conflits :

- Footer : les deux corrections sont conservées. FooterLink sort du composant
  (Alexis), la grille passe en une colonne sous 640 px et l'adresse email peut
  se couper (audit responsive).
- Admin : le BOM du CSV adopte la séquence échappée d'Alexis, plus lisible que
  le caractère invisible et sans règle de lint à désactiver.
- Contact : version d'Alexis retenue, plus complète, avec son formulaire.
- Connexion, Inscription : logos SVG conservés dans leur version nettoyée,
  avec aria-hidden ; les gestionnaires OAuth et les champs d'inscription
  restent en place.
- UserNav, Profile, services/admin : versions locales conservées.
- docker-compose : structure Traefik et domaines HTTPS d'Alexis, sur lesquels
  sont regreffés FRONTEND_URL et les identifiants OAuth par substitution.
- Les deux package-lock régénérés depuis les package.json fusionnés.

Aligne au passage les derniers chemins d'API restés relatifs sur VITE_API_URL,
sans quoi le bouton Google et la configuration du jeu visaient le domaine du
front en déploiement.

Fiabilise trois tests de tirage au sort : la table GrandPrizeWinner est unique
pour tout le jeu et le vivier contient de vrais comptes, donc les suites
mettent désormais un éventuel tirage réel de côté et n'attendent plus de
connaître le gagnant à l'avance.
2026-08-27 11:02:39 +02:00
9cf88d9f5a release: promote 1.0.4 to production
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 60.55% (333/550) * Lines of Code: 550 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 86.14% (230/267) * Lines of Code: 267 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-24 20:11:29 +00:00
4d7ca7945b ci: make SonarQube quality gate non-blocking
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 60.91% (335/550) * Lines of Code: 550 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 86.14% (230/267) * Lines of Code: 267 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-24 20:06:32 +00:00
c22c72e99c fix: provide Node.js to SonarQube analysis
Some checks failed
Tests / Backend - Tests skipped: 7, passed: 27
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 60.91% (335/550) * Lines of Code: 550 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 86.14% (230/267) * Lines of Code: 267 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-24 19:53:42 +00:00
efe5073aaa fix: use existing SonarQube project key
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 60.55% (333/550) * Lines of Code: 550 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 86.14% (230/267) * Lines of Code: 267 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-24 19:45:51 +00:00
39b7b7f8a4 fix: avoid duplicate SonarQube test indexing
Some checks failed
Tests / Backend - Tests skipped: 7, passed: 27
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 60.55% (333/550) * Lines of Code: 550 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 86.14% (230/267) * Lines of Code: 267 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-24 19:42:55 +00:00
f8c02b3cfc ci: integrate SonarQube analysis and quality gate
Some checks failed
Tests / Backend - Tests skipped: 7, passed: 27
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 60.55% (333/550) * Lines of Code: 550 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 86.14% (230/267) * Lines of Code: 267 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-24 19:38:44 +00:00
35bffc8a0b ci: publish coverage reports in Jenkins
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
Backend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 60.55% (333/550) * Lines of Code: 550 #### Quality Gates Summary No active quality gates.
Tests / Frontend - Lint, Tests et Build passed: 45
Frontend Coverage #### Project Overview No changes detected, that affect the code coverage. * Line Coverage: 86.14% (230/267) * Lines of Code: 267 #### Quality Gates Summary No active quality gates.
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-24 18:51:03 +00:00
2d54a7384f merge: integrate quality reports
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
Tests / Frontend - Lint, Tests et Build passed: 45
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-24 18:30:32 +00:00
ab8ea038c0 ci: publish frontend test and coverage reports
Some checks failed
Tests / Backend - Tests No test results found
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-24 18:25:58 +00:00
87ad8988fa merge: synchronize GitHub documentation history
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-23 11:06:21 +00:00
23c548992d add readMe Files
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-23 11:04:09 +00:00
7513bf45d6 release: promote 1.0.3 to production
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-23 10:46:26 +00:00
bdcdf1a4da merge: promote Google Analytics update to release-1.0.3
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-23 10:44:45 +00:00
79e10946b8 merge: integrate Google Analytics
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-23 10:35:40 +00:00
79f463d280 feat: integrate consent-based Google Analytics 2026-08-23 10:35:33 +00:00
6096da5bb8 add readMe Files 2026-08-21 02:32:55 +02:00
ad147eb930 release: promote 1.0.3 to production
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-20 23:55:16 +00:00
a09e9f17b5 fix: load OAuth secrets from server env file
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-20 23:44:36 +00:00
a40a62d600 fix: use backend API URL for Google OAuth
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-20 23:36:54 +00:00
12685c1b22 fix: resolve deployment compose conflicts
All checks were successful
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-20 23:27:51 +00:00
3a0dd25ed2 ci: seed test database before backend tests
Some checks failed
Tests / Backend - Tests skipped: 7, passed: 27
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-20 23:15:38 +00:00
a572fa1a15 merge: integrate Google OAuth and application fixes into develop
Some checks failed
Tests / Backend - Tests failed: 25, skipped: 7, passed: 6
THE-TIP-TOP-MULTIBRANCH/pipeline/head There was a failure building this commit
2026-08-20 23:09:19 +00:00
f9d72d45e2 merge: integrate Google OAuth and latest application fixes 2026-08-20 23:01:39 +00:00
Emma781227
2cf399d6d0 correction des bugue front end 2026-08-20 21:49:36 +02:00
Emma781227
e895325254 refont de la connexion par google et validation de lenvoie de mail a l'utilisater 2026-08-19 21:56:18 +02:00
Emma781227
fa21d9af2a ajout des champs date de naissance et gender sur l'inscription, modification de la permition de lancer le jeu concour par l'administrateur, envoie des mail a chaque lot remporter 2026-08-18 15:06:51 +02:00
Alexis SANOGO
61dcbda581 FAQ depuis la page d'accueil 2026-08-17 23:21:04 +02:00
Alexis SANOGO
21648ab171 Ajout de logo authentification Facebook / Google 2026-08-17 21:50:21 +02:00
6ee3dfdb87 release: promote 1.0.2 to production
All checks were successful
Tests / Backend - Tests passed: 8
THE-TIP-TOP-MULTIBRANCH/pipeline/head This commit looks good
2026-08-17 15:59:13 +00:00
d9d2eaba3d merge: promote validated develop to main 2026-08-17 14:55:36 +00:00
Emma781227
8adf780da2 Variables de connexion externe dans les environnements Docker
Ajoute FRONTEND_URL, absent des trois environnements alors que le callback
OAuth et la liste d'origines CORS en dépendent : sans lui le backend
retombait sur localhost:5173 et refusait le front déployé.

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

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

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

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

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

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

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

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

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

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

154
Jenkinsfile vendored
View File

@@ -49,6 +49,7 @@ pipeline {
env.DEPLOY_NETWORK = 'tiptop-dev'
env.FRONTEND_API_URL = "https://api-dev.dsp5-archi-o24a-4-5-g4.fr"
env.FRONTEND_GA_ID = ""
} else if (env.BRANCH_NAME.startsWith('release-')) {
env.DEPLOY_ENABLED = 'true'
@@ -59,6 +60,7 @@ pipeline {
env.DEPLOY_NETWORK = 'tiptop-preprod'
env.FRONTEND_API_URL = "https://api-preprod.dsp5-archi-o24a-4-5-g4.fr"
env.FRONTEND_GA_ID = ""
} else if (env.BRANCH_NAME == 'main') {
env.DEPLOY_ENABLED = 'true'
@@ -69,6 +71,7 @@ pipeline {
env.DEPLOY_NETWORK = 'tiptop-prod'
env.FRONTEND_API_URL = "https://api.dsp5-archi-o24a-4-5-g4.fr"
env.FRONTEND_GA_ID = "G-4FTNTPJDRJ"
} else {
env.DEPLOY_ENABLED = 'false'
@@ -78,13 +81,19 @@ pipeline {
env.COMPOSE_PROJECT = ''
env.DEPLOY_NETWORK = ''
env.FRONTEND_API_URL = ''
env.FRONTEND_GA_ID = ""
}
env.TURNSTILE_SITE_KEY = sh(
script: "grep '^TURNSTILE_SITE_KEY=' /opt/furious-ducks/secrets/tiptop-mail.env | cut -d= -f2-",
returnStdout: true
).trim()
echo "Branche : ${env.BRANCH_NAME}"
echo "Tag Docker : ${env.IMAGE_TAG}"
echo "Environnement : ${env.DEPLOY_ENV}"
echo "Déploiement activé : ${env.DEPLOY_ENABLED}"
echo "API frontend : ${env.FRONTEND_API_URL}"
echo "Turnstile : ${env.TURNSTILE_SITE_KEY ? '[SET]' : '[EMPTY]'}"
}
}
}
@@ -163,28 +172,48 @@ pipeline {
npm ci --prefer-offline --no-audit --no-fund &&
npx prisma generate &&
npx prisma migrate deploy &&
npx prisma db seed &&
npm test
"
'''
}
post {
always {
junit(
testResults: 'backend/test-results/*.xml',
allowEmptyResults: true
)
always {
junit(
testResults: 'backend/test-results/*.xml',
allowEmptyResults: true
)
archiveArtifacts(
artifacts: 'backend/coverage/**',
fingerprint: true,
allowEmptyArchive: true
)
}
}
recordCoverage(
tools: [[
parser: 'LCOV',
pattern: 'backend/coverage/lcov.info'
]],
sourceCodeRetention: 'EVERY_BUILD',
id: 'backend-coverage',
name: 'Backend Coverage'
)
publishHTML([
allowMissing: true,
alwaysLinkToLastBuild: true,
keepAll: true,
reportDir: 'backend/coverage',
reportFiles: 'index.html',
reportName: 'Backend Coverage HTML'
])
archiveArtifacts(
artifacts: 'backend/coverage/**',
fingerprint: true,
allowEmptyArchive: true
)
}
}
}
stage('Frontend - Lint et Build') {
stage('Frontend - Lint, Tests et Build') {
steps {
sh '''
docker run --rm \
@@ -195,8 +224,8 @@ pipeline {
docker run --rm \
--volumes-from furious-jenkins \
-v tiptop-npm-cache-frontend:/npm-cache \
--user "$(id -u):$(id -g)" \
-e HOME=/tmp \
-e CHROMIUM_PATH=/usr/bin/chromium \
-e NODE_OPTIONS=--dns-result-order=ipv4first \
-e NPM_CONFIG_CACHE=/npm-cache \
-e NPM_CONFIG_FETCH_RETRIES=5 \
@@ -206,14 +235,96 @@ pipeline {
-w "${WORKSPACE}/frontend" \
node:22-alpine \
sh -c "
apk add --no-cache chromium nss freetype harfbuzz ca-certificates ttf-freefont &&
export CHROMIUM_PATH=/usr/bin/chromium &&
npm ci --prefer-offline --no-audit --no-fund &&
npm run lint &&
npm run test:coverage &&
npm run build
"
'''
}
post {
always {
junit(
testResults: 'frontend/test-results/*.xml',
allowEmptyResults: true
)
recordCoverage(
tools: [[
parser: 'LCOV',
pattern: 'frontend/coverage/lcov.info'
]],
sourceCodeRetention: 'EVERY_BUILD',
id: 'frontend-coverage',
name: 'Frontend Coverage'
)
publishHTML([
allowMissing: true,
alwaysLinkToLastBuild: true,
keepAll: true,
reportDir: 'frontend/coverage',
reportFiles: 'index.html',
reportName: 'Frontend Coverage HTML'
])
archiveArtifacts(
artifacts: 'frontend/coverage/**',
fingerprint: true,
allowEmptyArchive: true
)
}
}
}
stage('SonarQube Analysis') {
when {
branch 'develop'
}
tools {
nodejs 'node22'
}
steps {
script {
def scannerHome = tool 'sonar-scanner'
withEnv(["SCANNER_HOME=${scannerHome}"]) {
withSonarQubeEnv('sonarqube') {
sh '''
echo "Node utilisé par Sonar :"
node -v
"${SCANNER_HOME}/bin/sonar-scanner" \
-Dsonar.projectKey=THE-TIP-TOP \
-Dsonar.projectName=THE-TIP-TOP \
-Dsonar.sources=backend/src,frontend/src \
-Dsonar.tests=backend/tests,frontend/src/test \
-Dsonar.exclusions=frontend/src/test/**,backend/tests/**,**/node_modules/**,**/coverage/**,**/dist/** \
-Dsonar.test.inclusions=backend/tests/**,frontend/src/test/** \
-Dsonar.javascript.lcov.reportPaths=backend/coverage/lcov.info,frontend/coverage/lcov.info \
-Dsonar.sourceEncoding=UTF-8
'''
}
}
}
}
}
stage('SonarQube Quality Gate') {
when {
branch 'develop'
}
steps {
timeout(time: 5, unit: 'MINUTES') {
waitForQualityGate abortPipeline: true
}
}
}
stage('Construction des images Docker') {
when {
expression {
@@ -223,14 +334,23 @@ pipeline {
steps {
sh '''
set -a
. /opt/furious-ducks/secrets/tiptop-mail.env
set +a
test -n "${TURNSTILE_SITE_KEY}"
echo "Turnstile site key chargée (longueur: ${#TURNSTILE_SITE_KEY})"
docker build \
-t "${BACKEND_IMAGE}:${IMAGE_TAG}" \
backend
docker build \
--build-arg VITE_API_URL="${FRONTEND_API_URL}" \
-t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \
frontend
--build-arg VITE_API_URL="${FRONTEND_API_URL}" \
--build-arg VITE_GA_ID="${FRONTEND_GA_ID}" \
--build-arg VITE_TURNSTILE_SITE_KEY="${TURNSTILE_SITE_KEY:-}" \
-t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \
frontend
'''
}
}

781
README.md Normal file
View File

@@ -0,0 +1,781 @@
# Thé Tip Top – Plateforme du jeu-concours
## Présentation
**Thé Tip Top** est une application web complète développée dans le cadre d’un projet de certification **Expert en Stratégie et Transformation Digitale**.
Le projet simule le système d’information d’un jeu-concours organisé à l’occasion de l’ouverture de la 10ᵉ boutique Thé Tip Top à Nice.
La plateforme permet aux clients de participer avec un code unique présent sur leur ticket, de consulter leurs gains et de les récupérer en boutique. Elle fournit également des interfaces dédiées aux employés et aux administrateurs.
Au-delà de l’application, le projet met en œuvre une chaîne complète de **CI/CD**, plusieurs environnements isolés, un registre Docker privé, un reverse proxy HTTPS et une stack de supervision.
> **Projet étudiant — client fictif Thé Tip Top.**
---
## Fonctionnalités principales
### Espace client
- création de compte ;
- vérification de l’adresse email ;
- connexion email / mot de passe ;
- connexion avec Google OAuth 2.0 ;
- gestion du profil ;
- saisie d’un code ticket unique ;
- participation au jeu-concours ;
- consultation des gains.
### Espace employé
- authentification avec rôle `EMPLOYEE` ;
- recherche d’un gain ;
- contrôle des informations associées au ticket ;
- validation de la remise d’un lot.
### Espace administrateur
- authentification avec rôle `ADMIN` ;
- tableau de bord et statistiques ;
- consultation des participants ;
- gestion des données nécessaires à l’administration ;
- création de comptes employés ;
- configuration du lancement du jeu-concours ;
- tirage au sort final ;
- consultation du grand gagnant.
### Jeu-concours
Le système gère plusieurs phases :
```text
NOT_CONFIGURED
↓
SCHEDULED
↓
OPEN
↓
CLAIM_ONLY
↓
CLOSED
```
Parmi les principales règles métier :
- un ticket ne peut être utilisé qu’une seule fois ;
- les tickets du jeu sont gagnants ;
- les gains sont associés aux tickets ;
- seuls les clients participent au jeu ;
- plusieurs participations d’un même client n’augmentent pas ses chances au tirage final ;
- un seul grand gagnant peut être enregistré ;
- le tirage final respecte les contraintes temporelles définies dans l’application.
---
# Architecture générale
```text
UTILISATEURS
│
HTTPS
│
▼
┌─────────┐
│ Traefik │
└────┬────┘
│
┌────────────┴────────────┐
│ │
▼ ▼
Frontend React Backend API
Vite + Nginx Node.js + Express
│ │
│ ▼
│ PostgreSQL
│ + Prisma
│
└────────── API REST ─────┘
```
---
# Stack technique
## Frontend
- React
- Vite
- React Router
- Tailwind CSS
- Vitest
- Testing Library
- ESLint
- Nginx
## Backend
- Node.js 22
- Express.js
- Prisma ORM 6
- PostgreSQL 16
- JWT
- Passport.js
- Google OAuth 2.0
- Bcrypt
- Zod
- Nodemailer
- Helmet
- express-rate-limit
- Swagger / OpenAPI
- Jest
- Supertest
## DevOps
- Git
- Gitea
- Jenkins
- Docker
- Docker Compose
- Docker Registry privé
- Traefik
- HTTPS
- Let's Encrypt
## Monitoring
- Prometheus
- Grafana
- cAdvisor
- Node Exporter
---
# Structure du dépôt
```text
THE-TIP-TOP/
│
├── backend/
│ ├── prisma/
│ │ ├── migrations/
│ │ ├── schema.prisma
│ │ └── seed.js
│ ├── src/
│ ├── tests/
│ ├── Dockerfile
│ ├── package.json
│ └── README.md
│
├── frontend/
│ ├── src/
│ │ ├── components/
│ │ ├── context/
│ │ ├── pages/
│ │ ├── services/
│ │ └── test/
│ ├── Dockerfile
│ ├── nginx.conf
│ ├── package.json
│ └── README.md
│
├── deploy/
│ ├── dev/
│ │ └── docker-compose.yml
│ ├── preprod/
│ │ └── docker-compose.yml
│ └── prod/
│ └── docker-compose.yml
│
├── Jenkinsfile
└── README.md
```
Pour les informations spécifiques à chaque application :
- [`backend/README.md`](backend/README.md)
- [`frontend/README.md`](frontend/README.md)
---
# Environnements
Le projet utilise trois environnements distincts.
| Environnement | Branche | Frontend | API |
|---|---|---|---|
| DEV | `develop` | `https://dev.dsp5-archi-o24a-4-5-g4.fr` | `https://api-dev.dsp5-archi-o24a-4-5-g4.fr` |
| PREPROD | `release-*` | `https://preprod.dsp5-archi-o24a-4-5-g4.fr` | `https://api-preprod.dsp5-archi-o24a-4-5-g4.fr` |
| PROD | `main` | `https://dsp5-archi-o24a-4-5-g4.fr` | `https://api.dsp5-archi-o24a-4-5-g4.fr` |
Les bases PostgreSQL et conteneurs applicatifs sont séparés entre les environnements.
---
# Stratégie Git
Le workflow de branches est organisé autour de plusieurs niveaux de validation.
```text
feature/*
│
│ Pull Request / Merge
▼
develop
│
│ CI + déploiement DEV
▼
release-*
│
│ CI + déploiement PREPROD
▼
main
│
│ validation manuelle Jenkins
▼
PRODUCTION
```
### `feature/*`
Branches utilisées pour le développement des fonctionnalités.
### `develop`
Branche d’intégration. Un changement validé sur cette branche est déployé automatiquement en DEV.
### `release-*`
Branches de préparation d’une version. Elles sont déployées en PREPROD pour validation avant production.
### `main`
Branche stable de production. Le déploiement PROD nécessite une validation manuelle dans Jenkins.
---
# Pipeline CI/CD
Le fichier `Jenkinsfile` placé à la racine du dépôt définit la chaîne d’intégration et de déploiement.
```text
Push Gitea
│
▼
Jenkins
│
├── Checkout
│
├── Vérification workspace
│
├── PostgreSQL CI temporaire
│
├── Prisma migrations
│
├── Prisma seed
│
├── Tests backend Jest
│
├── Publication JUnit
│
├── Frontend ESLint
│
├── Frontend Vite build
│
├── Docker build
│
├── Push Registry privé
│
└── Docker Compose deploy
│
▼
DEV / PREPROD / PROD
```
## Comportement selon la branche
### Feature
```text
Push
↓
Tests
↓
Lint
↓
Build
```
Pas de déploiement automatique.
### Develop
```text
Push develop
↓
CI
↓
Images Docker
↓
Registry
↓
Déploiement DEV
```
### Release
```text
Push release-*
↓
CI
↓
Images Docker
↓
Registry
↓
Déploiement PREPROD
```
### Main
```text
Push main
↓
CI
↓
Images Docker
↓
Registry
↓
Validation Jenkins
↓
Déploiement PROD
```
Cette validation constitue une barrière supplémentaire avant la mise en production.
---
# Tests automatisés
## Backend
Les tests utilisent **Jest** et **Supertest**.
Ils couvrent notamment :
- inscription ;
- connexion ;
- parcours E2E ;
- autorisations ;
- participation ;
- phases du jeu ;
- tirage au sort ;
- notifications liées aux gains ;
- notification du grand gagnant ;
- authentification Google ;
- gestion du mot de passe ;
- comportements liés aux emails.
Commande :
```bash
cd backend
npm test
```
Dans Jenkins, PostgreSQL est lancé temporairement pour les tests. Les migrations et données initiales sont appliquées avant l’exécution des suites.
## Frontend
Les tests utilisent **Vitest** et **Testing Library**.
Ils couvrent notamment :
- connexion ;
- inscription ;
- formulaire d’inscription ;
- saisie du code unique ;
- routes protégées ;
- contexte d’authentification ;
- gestion des erreurs d’authentification ;
- services ;
- bannière de cookies.
Commandes :
```bash
cd frontend
npm run lint
npm test -- --run
npm run build
```
---
# Authentification et sécurité
Le projet met en œuvre plusieurs mécanismes de sécurité.
## Authentification
- JWT ;
- email / mot de passe ;
- Google OAuth 2.0 ;
- vérification email.
## Protection des mots de passe
Les mots de passe sont hachés avec **Bcrypt** avant stockage.
## Validation
Les données reçues par l’API sont validées avec **Zod**.
## Contrôle d’accès
Des middlewares backend contrôlent :
- l’authentification ;
- le rôle ;
- l’accès aux routes protégées.
## Sécurité HTTP
Le backend utilise notamment :
- Helmet ;
- rate limiting ;
- validation des entrées.
## HTTPS
Traefik assure le routage HTTP/HTTPS vers les différents services.
---
# Gestion des secrets
Les secrets ne doivent jamais être stockés directement dans le dépôt Git.
Sont notamment concernés :
```text
JWT_SECRET
DATABASE_URL / mots de passe PostgreSQL
MAIL_USER
MAIL_PASS
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
FACEBOOK_APP_ID
FACEBOOK_APP_SECRET
identifiants du Docker Registry
```
Les valeurs sensibles de déploiement sont externalisées sur le serveur ou injectées par Jenkins.
> Les fichiers contenant des secrets réels ne doivent jamais être ajoutés au dépôt.
---
# Docker
Le frontend et le backend disposent chacun de leur propre image Docker.
## Backend
```text
Node.js 22
↓
npm ci
↓
Prisma generate
↓
Application Express
```
## Frontend
Build multi-stage :
```text
Node.js 22
↓
npm ci
↓
Vite build
↓
Nginx
```
Les images sont versionnées et publiées dans le Registry privé par Jenkins.
---
# Docker Compose
Chaque environnement possède son propre fichier Compose :
```text
deploy/dev/docker-compose.yml
deploy/preprod/docker-compose.yml
deploy/prod/docker-compose.yml
```
Chaque stack contient notamment :
```text
Frontend
Backend
PostgreSQL
```
Les stacks sont isolées avec leurs propres conteneurs, réseaux, données et paramètres d’environnement.
---
# Reverse proxy et HTTPS
**Traefik** constitue le point d’entrée HTTP/HTTPS de l’infrastructure.
Il permet notamment :
- le routage par nom de domaine ;
- l’exposition des frontends ;
- l’exposition des APIs ;
- la terminaison TLS ;
- la gestion des certificats HTTPS.
Seuls les ports nécessaires sont exposés publiquement.
---
# Monitoring
La plateforme dispose d’une stack de supervision.
```text
Node Exporter ──┐
│
cAdvisor ───────┼──► Prometheus ───► Grafana
│
Services ───────┘
```
### Prometheus
Collecte les métriques.
### Grafana
Permet de construire et consulter les tableaux de bord.
### cAdvisor
Expose les métriques relatives aux conteneurs Docker.
### Node Exporter
Expose les métriques système du serveur.
---
# Documentation API
Le backend expose une documentation Swagger / OpenAPI.
En environnement local :
```text
http://localhost:5000/api-docs
```
Elle permet de :
- consulter les routes ;
- consulter les schémas ;
- examiner les paramètres ;
- tester les endpoints ;
- utiliser l’authentification prévue par l’API.
---
# Installation locale
## 1. Cloner le dépôt
```bash
git clone <URL_DU_DEPOT>
cd THE-TIP-TOP
```
## 2. Backend
```bash
cd backend
npm install
```
Configurer les variables d’environnement locales puis :
```bash
npx prisma generate
npx prisma migrate dev
npx prisma db seed
npm run dev
```
## 3. Frontend
Dans un second terminal :
```bash
cd frontend
npm install
npm run dev
```
Le frontend local est généralement accessible sur :
```text
http://localhost:5173
```
et l’API sur :
```text
http://localhost:5000
```
Consulter les README des sous-projets pour les détails de configuration.
---
# Base de données
Le projet utilise **PostgreSQL 16** avec **Prisma ORM 6**.
Commandes Prisma utiles :
```bash
npx prisma generate
npx prisma migrate dev
npx prisma migrate deploy
npx prisma db seed
npx prisma studio
```
En environnement déployé, les migrations sont appliquées avec `prisma migrate deploy`.
---
# Qualité logicielle
Le projet applique plusieurs niveaux de contrôle :
```text
Code
↓
ESLint
↓
Tests unitaires / fonctionnels
↓
Tests backend / E2E
↓
Build
↓
Docker Build
↓
Déploiement
```
Un échec dans une étape critique provoque l’échec du pipeline et empêche la promotion normale de la version.
---
# Infrastructure DevOps
Vue simplifiée :
```text
┌───────────────┐
│ Gitea │
└───────┬───────┘
│ Webhook
▼
┌───────────────┐
│ Jenkins │
└───────┬───────┘
│
Tests / Build / Docker
│
▼
┌───────────────┐
│Docker Registry│
└───────┬───────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
DEV PREPROD PROD
│ │ │
└───────────────┼───────────────┘
▼
Traefik
│
HTTPS
```
---
# État du projet
Le projet dispose notamment de :
- frontend React opérationnel ;
- backend Express opérationnel ;
- PostgreSQL et Prisma ;
- authentification JWT ;
- Google OAuth 2.0 ;
- vérification email ;
- gestion CLIENT / EMPLOYEE / ADMIN ;
- jeu-concours et gestion des gains ;
- tirage final ;
- tests automatisés ;
- documentation Swagger ;
- images Docker ;
- Registry privé ;
- pipeline Jenkins multibranch ;
- environnements DEV / PREPROD / PROD ;
- déploiements Docker Compose ;
- reverse proxy Traefik ;
- HTTPS ;
- monitoring Prometheus / Grafana.
---
# Documentation complémentaire
Documentation backend :
```text
backend/README.md
```
Documentation frontend :
```text
frontend/README.md
```
Ces fichiers détaillent respectivement l’API et l’application React.
---
# Projet académique
Projet réalisé dans le cadre de la certification :
**Expert en Stratégie et Transformation Digitale**
Le cas **Thé Tip Top** est utilisé comme contexte métier fictif pour la conception, le développement, les tests, la sécurisation, l’industrialisation et le déploiement de l’application.

View File

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

15
backend/.gitignore vendored
View File

@@ -2,4 +2,17 @@ node_modules/
.env
.prisma
coverage/
dist/
dist/
test-results/
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,368 +1,285 @@
# Thé Tip Top – Backend API
# Thé Tip Top – Backend API
## Présentation
Ce projet correspond au backend du jeu-concours organisé par **Thé Tip Top** à l'occasion de l'ouverture de sa 10ᵉ boutique à Nice.
Backend REST du jeu-concours fictif **Thé Tip Top**, réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**.
L'application permet aux clients de participer à un jeu-concours via un code présent sur leur ticket de caisse, de consulter leurs gains et de réclamer leurs lots en boutique.
L’API fournit les services des espaces **CLIENT**, **EMPLOYEE** et **ADMIN** et prend notamment en charge :
Le backend expose une **API** **REST** sécurisée utilisée par :
- l'application client (participants)
- l'espace employé en boutique
- l'espace administrateur
- les futurs services externes de l'écosystème Thé Tip Top
- inscription, vérification email et connexion ;
- authentification JWT et Google OAuth 2.0 ;
- rôles et autorisations ;
- tickets, participations et gains ;
- remise des lots ;
- configuration des phases du jeu ;
- tirage au sort final ;
- notifications email ;
- administration et statistiques.
---
## Fonctionnalités
## Stack technique
### Authentification
| Technologie | Utilisation |
|---|---|
| Node.js 22 | Runtime |
| Express.js | API REST |
| PostgreSQL 16 | Base de données |
| Prisma ORM 6 | ORM et migrations |
| JWT | Authentification |
| Passport.js | Authentification externe |
| Google OAuth 2.0 | Connexion Google |
| Bcrypt | Hachage des mots de passe |
| Zod | Validation |
| Nodemailer | Emails |
| Helmet | Sécurité HTTP |
| express-rate-limit | Rate limiting |
| Swagger / OpenAPI | Documentation |
| Jest / Supertest | Tests |
| Docker | Conteneurisation |
- Inscription utilisateur
- Connexion sécurisée
- Authentification **JWT**
- Gestion des rôles (**CLIENT**, **EMPLOYEE**, **ADMIN**)
- Hachage des mots de passe avec Bcrypt
- Validation des données avec **Zod**
## Authentification
### Jeu-concours
### Email / mot de passe
- Génération automatique des lots
- Génération des tickets gagnants
- Participation via code ticket unique
- Attribution automatique des gains
- Historique des participations
- Historique des gains remportés
Le parcours comprend l’inscription, le hachage Bcrypt, l’envoi d’un code de vérification par email, la validation du compte, la connexion et la génération d’un JWT.
### Espace Employé
### Google OAuth 2.0
- Recherche d'un gain via un code ticket
- Vérification du statut du gain
- Validation de la remise d'un lot
- Mise à jour automatique du statut du gain
### Espace Administrateur
- Tableau de bord statistique
- Liste des participants
- Liste des contacts ayant accepté les communications marketing
- Création de comptes employés
- Tirage au sort du grand gagnant
- Consultation du gagnant du gros lot
### Documentation API
- Documentation interactive avec **Swagger UI**
- Génération automatique de la documentation via **Swagger JSDoc**
- Tests des endpoints directement depuis le navigateur
---
## Architecture Technique
### Backend
- Node.js
- Express.js
### Base de données
- PostgreSQL
- **Prisma **ORM** 6**
- Prisma Client 6
- Prisma Migrate
- Prisma Studio
### Validation des données
- **Zod**
- Middleware de validation personnalisé
### Sécurité
- **JWT** (**JSON** Web Token)
- Bcrypt
- Gestion des rôles et permissions
- Validation des entrées utilisateur
### Documentation
- Swagger UI
- Swagger JSDoc
- OpenAPI 3.0
---
## Installation
### Cloner le projet
```bash
git clone <url-du-repository>
cd the-tip-top-backend
```text
Utilisateur
↓
Frontend
↓
API /api/auth/google
↓
Google OAuth 2.0
↓
Callback backend
↓
Création / récupération utilisateur
↓
JWT
↓
Redirection frontend
```
### Installer les dépendances
Les secrets OAuth restent hors du dépôt Git.
```bash
npm install
```
### Variables d'environnement
Créer un fichier `.env` à la racine du projet :
```env **PORT**=**5000**```
```DATABASE_URL="postgresql://postgres:password@localhost:**5432**/the_tip_top_db*```
```JWT_SECRET=*your_secret_key" ```
---
## Base de données
### Générer les migrations
```bash
npx prisma migrate dev
```
### Générer Prisma Client
```bash
npx prisma generate
```
### Exécuter le seed de la base
```bash
npx prisma db seed
```
### Générer les tickets du jeu-concours
```bash
npm run generate:tickets
```
### Ouvrir Prisma Studio
```bash
npx prisma studio
```
---
## Technologies utilisées
| Technologie | Version |
| ----------- | ---------------- |
| Node.js | 20+ |
| Express.js | 5 |
| PostgreSQL | 16+ |
| Prisma ORM | 6 |
| JWT | Dernière version |
| Bcrypt | Dernière version |
| Zod | Dernière version |
| Swagger UI | Dernière version |
---
## Lancement du projet
### Développement
Le projet utilise **Nodemon** afin de redémarrer automatiquement le serveur lors des modifications du code.
```bash
npm run dev
```
Serveur :
```txt [http://localhost:**5000**](http://localhost:**5000**) ```
---
## Documentation API
La documentation Swagger est accessible à l'adresse :
```txt [http://localhost:**5000**/api-docs](http://localhost:**5000**/api-docs) ```
Fonctionnalités disponibles :
- Consultation de toutes les routes
- Visualisation des schémas de données
- Test des endpoints
- Authentification via **JWT** avec le bouton **Authorize**
---
## Gestion des rôles
## Rôles
### CLIENT
Peut :
- Participer au jeu-concours
- Consulter ses gains
- Consulter son profil
- participer au jeu ;
- consulter ses gains ;
- gérer son profil.
### EMPLOYEE
Peut :
- Rechercher un gain
- Vérifier les informations du gagnant
- Valider la remise d'un lot
- rechercher et vérifier un gain ;
- valider la remise d’un lot.
### ADMIN
- consulter les statistiques ;
- créer des employés ;
- configurer le jeu ;
- effectuer le tirage final ;
- consulter le grand gagnant.
Peut :
## Jeu-concours
- Accéder aux statistiques
- Créer des employés
- Gérer les campagnes emailing
- Effectuer le tirage au sort final
- Consulter le gagnant du gros lot
Phases applicatives :
---
- `NOT_CONFIGURED`
- `SCHEDULED`
- `OPEN`
- `CLAIM_ONLY`
- `CLOSED`
## Routes API
Principales règles métier :
### Auth
- un ticket ne peut être utilisé qu’une fois ;
- les tickets sont gagnants ;
- les comptes EMPLOYEE et ADMIN ne participent pas comme clients ;
- plusieurs participations d’un client n’augmentent pas ses chances au tirage final ;
- un seul grand gagnant peut être enregistré ;
- le tirage final respecte les contraintes temporelles du jeu.
| Méthode | Route |
| ------- | ------------------ |
| POST | /api/auth/register |
| POST | /api/auth/login |
| GET | /api/auth/me |
## Notifications email
### Game
Nodemailer est utilisé notamment pour la vérification des comptes et les notifications liées aux gains et au tirage final. La configuration SMTP est fournie par variables d’environnement.
| Méthode | Route |
| ------- | --------------------- |
| POST | /api/game/participate |
| GET | /api/game/my-gains |
## Sécurité
### Employee
- Bcrypt ;
- JWT ;
- Google OAuth 2.0 ;
- Zod ;
- contrôle d’accès par rôle ;
- Helmet ;
- rate limiting ;
- secrets externalisés ;
- HTTPS via Traefik ;
- séparation DEV / PREPROD / PROD.
| Méthode | Route |
| ------- | ---------------------------- |
| GET | /api/employee/ticket/ |
| PATCH | /api/employee/claim//deliver |
### Admin
| Méthode | Route |
| ------- | -------------------- |
| GET | /api/admin/stats |
| GET | /api/admin/users |
| GET | /api/admin/emailing |
| POST | /api/admin/employees |
| POST | /api/admin/draw |
| GET | /api/admin/draw |
---
## Validation avec Zod
Toutes les données reçues par l'**API** sont validées avant traitement grâce à **Zod**.
Exemples :
- Inscription utilisateur
- Connexion
- Création d'employés
- Participation au jeu-concours
- Validation des paramètres de requête
Cette approche garantit :
- Des données cohérentes
- Une meilleure sécurité
- Des messages d'erreur explicites
- Une maintenance facilitée
---
> Ne jamais versionner les mots de passe, tokens, secrets JWT, secrets OAuth ou identifiants SMTP.
## Modèle de données principal
### User
- `User` : utilisateurs et rôles CLIENT / EMPLOYEE / ADMIN.
- `Ticket` : code unique, lot associé et état d’utilisation.
- `Lot` : lot, description, valeur et répartition.
- `Participation` : participation d’un utilisateur avec un ticket.
- `Claim` : gestion de la remise du gain.
- `GameSettings` : configuration temporelle du concours.
- `GrandPrizeWinner` : résultat du tirage final.
- **CLIENT**
- **EMPLOYEE**
- **ADMIN**
## Installation locale
### Ticket
Prérequis : Node.js 22, npm et PostgreSQL 16.
- Code unique
- Gain associé
- Utilisé / Non utilisé
### Lot
- Nom
- Description
- Valeur
- Pourcentage de répartition
### Participation
- Utilisateur
- Ticket
- Lot gagné
### Claim
- **PENDING**
- **DELIVERED**
- **CANCELLED**
### GrandPrizeWinner
- Gagnant du gros lot
- Date du tirage
- Récompense
---
## Prisma ORM 6
Le projet utilise **Prisma **ORM** 6** pour :
- La modélisation des données
- Les migrations de base de données
- Les requêtes **SQL** typées
- La génération automatique du client Prisma
- L'administration de la base via Prisma Studio
Commandes utiles :
```bash
npx prisma migrate dev npx prisma generate npx prisma db seed npx prisma studio
```bash
npm install
```
---
Exemple de `.env` local :
## Règles métier
```env
PORT=5000
NODE_ENV=development
DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db"
JWT_SECRET="change_me"
APP_URL="http://localhost:5000"
FRONTEND_URL="http://localhost:5173"
MAIL_HOST="smtp.example.com"
MAIL_PORT=587
MAIL_USER="user@example.com"
MAIL_PASS="change_me"
MAIL_FROM="Thé Tip Top <user@example.com>"
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
```
- Tous les tickets sont gagnants.
- Un ticket ne peut être utilisé qu'une seule fois.
- Les gains sont associés aux tickets dès leur génération.
- Plusieurs participations n'augmentent pas les chances de remporter le gros lot.
- Un seul gagnant du gros lot peut être enregistré.
- Les gains non récupérés dans les délais peuvent être annulés.
Ces valeurs sont uniquement des exemples.
---
## Prisma
## Auteur
```bash
npx prisma generate
npx prisma migrate dev
npx prisma migrate deploy
npx prisma db seed
npx prisma studio
```
Projet réalisé dans le cadre de la certification : **Expert en Stratégie et Transformation Digitale**
## Lancement
Client fictif : **Thé Tip Top**.
Développement :
```bash
npm run dev
```
Démarrage standard :
```bash
npm start
```
API locale généralement disponible sur `http://localhost:5000`.
## Tests
```bash
npm test
```
Les tests Jest/Supertest couvrent notamment l’inscription, la connexion, les autorisations, les participations, les phases du jeu, le tirage final, les notifications, l’authentification Google et des scénarios E2E.
Dans Jenkins, une base PostgreSQL temporaire est créée. Les migrations et le seed sont exécutés avant les tests, puis les résultats sont publiés au format JUnit.
## Documentation API
Swagger UI est accessible localement sur :
```text
http://localhost:5000/api-docs
```
## CI/CD
Le projet utilise Gitea, Jenkins, Docker et un Registry privé.
```text
feature/* → CI uniquement
develop → DEV automatique
release-* → PREPROD automatique
main → validation manuelle → PROD
```
Pipeline :
```text
Checkout
↓
Tests backend + PostgreSQL temporaire
↓
Lint + Build frontend
↓
Docker Build
↓
Registry
↓
Déploiement
↓
Contrôle des conteneurs
```
## Environnements
| Environnement | Frontend | API |
|---|---|---|
| DEV | `https://dev.dsp5-archi-o24a-4-5-g4.fr` | `https://api-dev.dsp5-archi-o24a-4-5-g4.fr` |
| PREPROD | `https://preprod.dsp5-archi-o24a-4-5-g4.fr` | `https://api-preprod.dsp5-archi-o24a-4-5-g4.fr` |
| PROD | `https://dsp5-archi-o24a-4-5-g4.fr` | `https://api.dsp5-archi-o24a-4-5-g4.fr` |
## Infrastructure
- Docker / Docker Compose ;
- Gitea ;
- Jenkins ;
- Docker Registry ;
- Traefik ;
- Let's Encrypt / HTTPS ;
- PostgreSQL ;
- Prometheus ;
- Grafana ;
- cAdvisor ;
- Node Exporter.
## Arborescence simplifiée
```text
backend/
├── prisma/
│ ├── migrations/
│ ├── schema.prisma
│ └── seed.js
├── src/
│ ├── config/
│ ├── controllers/
│ ├── docs/
│ ├── middlewares/
│ ├── routes/
│ ├── utils/
│ ├── validations/
│ └── app.js
├── tests/
├── Dockerfile
├── package.json
└── README.md
```
## Projet
Projet étudiant fictif réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**.
Client fictif : **Thé Tip Top**.

View File

@@ -3,6 +3,8 @@ module.exports = {
testMatch: ["**/tests/**/*.test.js"],
setupFiles: ["<rootDir>/tests/setupEnv.js"],
clearMocks: true,
testTimeout: 10000,

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "mustChangePassword" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -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"))
);

View File

@@ -24,12 +24,20 @@ enum ClaimStatus {
CANCELLED
}
enum AuthProvider {
LOCAL
GOOGLE
FACEBOOK
}
model User {
id Int @id @default(autoincrement())
firstname String
lastname String
email String @unique
password String
// Nul pour les comptes créés via un provider externe (Google, Facebook) :
// ces utilisateurs n'ont jamais choisi de mot de passe chez nous.
password String?
role Role @default(CLIENT)
birthdate DateTime?
gender String?
@@ -43,10 +51,21 @@ model User {
resetPasswordToken String?
resetPasswordExpires DateTime?
// Compte employé créé par un admin avec un mot de passe provisoire envoyé
// par email : tant qu'il vaut true, seul le choix d'un nouveau mot de passe
// est autorisé (voir auth.middleware.js).
mustChangePassword Boolean @default(false)
provider AuthProvider @default(LOCAL)
providerId String?
avatarUrl String?
tickets Ticket[]
participations Participation[]
grandPrizeWinner GrandPrizeWinner?
claimsValidated Claim[] @relation("EmployeeClaims")
@@unique([provider, providerId])
}
model Lot {

View File

@@ -1,4 +1,5 @@
const prisma = require("../src/config/prisma");
const { DEFAULT_START_DATE } = require("../src/utils/gameWindow");
async function main() {
await prisma.lot.deleteMany();
@@ -42,8 +43,8 @@ async function main() {
const existingSettings = await prisma.gameSettings.findFirst();
if (!existingSettings) {
await prisma.gameSettings.create({ data: {} });
console.log("Date de début du jeu-concours initialisée à aujourd'hui.");
await prisma.gameSettings.create({ data: { startDate: DEFAULT_START_DATE } });
console.log("Date de début du jeu-concours initialisée au 2 octobre 2026.");
} else {
console.log("Date de début du jeu-concours déjà définie, inchangée.");
}

View File

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

View File

@@ -0,0 +1,171 @@
const passport = require("passport");
const GoogleStrategy = require("passport-google-oauth20").Strategy;
const FacebookStrategy = require("passport-facebook").Strategy;
const crypto = require("crypto");
const prisma = require("./prisma");
const transporter = require("./mailer");
const getFrontendUrl = require("../utils/frontendUrl");
const { normalizeEmail } = require("../validations/email.validation");
const PROVIDER_LABELS = { GOOGLE: "Google", FACEBOOK: "Facebook" };
// Un compte créé via un provider externe n'a pas de mot de passe. On lui en
// propose un dès l'inscription, via le même mécanisme de jeton que la
// réinitialisation : sans mot de passe, l'utilisateur dépend entièrement du
// provider pour se connecter, et le formulaire de changement de son profil lui
// reste inaccessible.
const PASSWORD_SETUP_EXPIRY_MS = 1000 * 60 * 60 * 24; // 24 h
async function sendPasswordSetupEmail(user, providerLabel) {
const token = crypto.randomBytes(32).toString("hex");
await prisma.user.update({
where: { id: user.id },
data: {
resetPasswordToken: token,
resetPasswordExpires: new Date(Date.now() + PASSWORD_SETUP_EXPIRY_MS),
},
});
const setupUrl = `${getFrontendUrl()}/definir-mot-de-passe/${token}`;
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: user.email,
subject: "Définissez votre mot de passe Thé Tip Top",
html: `
<div style="font-family:sans-serif;max-width:480px;margin:auto">
<h2 style="color:#006B6B">Bienvenue ${user.firstname} !</h2>
<p>Votre compte a été créé avec ${providerLabel}. Vous pouvez continuer à vous
connecter de cette façon, ou définir un mot de passe pour accéder
également à votre compte sans passer par ${providerLabel}.</p>
<p style="text-align:center;padding:20px 0">
<a href="${setupUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
Définir mon mot de passe
</a>
</p>
<p style="color:#888;font-size:13px">Ce lien expire dans 24 heures. Si vous
ne souhaitez pas de mot de passe, ignorez simplement cet email : votre
connexion ${providerLabel} continuera de fonctionner.</p>
</div>
`,
});
}
// Callback de vérification commun à tous les providers : passport normalise
// le profil (id, name, emails, photos), seule la valeur de `provider` change.
function verifyOAuthProfile(provider) {
const providerLabel = PROVIDER_LABELS[provider];
return async (accessToken, refreshToken, profile, done) => {
try {
const email = normalizeEmail(profile.emails?.[0]?.value);
// Cas fréquent chez Facebook : compte ouvert avec un numéro de téléphone.
if (!email) {
return done(null, false, {
message: `Aucune adresse email n'est associée à ce compte ${providerLabel}.`,
});
}
// 1. Compte déjà lié à ce compte externe : connexion directe.
const byProvider = await prisma.user.findUnique({
where: {
provider_providerId: { provider, providerId: profile.id },
},
});
if (byProvider) return done(null, byProvider);
// 2. Un compte existe avec le même email : on le rattache au provider.
// L'email étant déjà validé par le provider, on en profite pour lever
// la vérification si elle était en attente.
const byEmail = await prisma.user.findUnique({ where: { email } });
if (byEmail) {
const linked = await prisma.user.update({
where: { id: byEmail.id },
data: {
provider,
providerId: profile.id,
isEmailVerified: true,
emailVerifyToken: null,
emailVerifyExpires: null,
avatarUrl: byEmail.avatarUrl || profile.photos?.[0]?.value || null,
},
});
return done(null, linked);
}
// 3. Première connexion : création du compte.
const created = await prisma.user.create({
data: {
firstname: profile.name?.givenName || profile.displayName || "Utilisateur",
lastname: profile.name?.familyName || "",
email,
password: null,
provider,
providerId: profile.id,
avatarUrl: profile.photos?.[0]?.value || null,
isEmailVerified: true,
},
});
// Un envoi qui échoue ne doit pas bloquer la connexion : le compte
// est valide, et « mot de passe oublié » permet de rattraper.
try {
await sendPasswordSetupEmail(created, providerLabel);
} catch (mailError) {
console.error("Erreur envoi email de definition du mot de passe:", mailError);
}
return done(null, created);
} catch (error) {
return done(error);
}
};
}
// Authentification stateless : le callback OAuth émet directement notre JWT,
// donc pas de session Express ni de serialize/deserialize à brancher.
const apiUrl = process.env.APP_URL || "http://localhost:5000";
const googleEnabled =
Boolean(process.env.GOOGLE_CLIENT_ID) && Boolean(process.env.GOOGLE_CLIENT_SECRET);
const facebookEnabled =
Boolean(process.env.FACEBOOK_APP_ID) && Boolean(process.env.FACEBOOK_APP_SECRET);
if (googleEnabled) {
passport.use(
new GoogleStrategy(
{
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: `${apiUrl}/api/auth/google/callback`,
scope: ["profile", "email"],
},
verifyOAuthProfile("GOOGLE")
)
);
}
if (facebookEnabled) {
passport.use(
new FacebookStrategy(
{
clientID: process.env.FACEBOOK_APP_ID,
clientSecret: process.env.FACEBOOK_APP_SECRET,
callbackURL: `${apiUrl}/api/auth/facebook/callback`,
// Sans profileFields, la Graph API ne renvoie que l'id et le nom.
profileFields: ["id", "displayName", "name", "emails", "photos"],
// appsecret_proof : empêche l'usage d'un access token volé ailleurs.
enableProof: true,
},
verifyOAuthProfile("FACEBOOK")
)
);
}
module.exports = { passport, googleEnabled, facebookEnabled, verifyOAuthProfile };

View File

@@ -1,6 +1,9 @@
const bcrypt = require("bcrypt");
const crypto = require("crypto");
const prisma = require("../config/prisma");
const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow");
const transporter = require("../config/mailer");
const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow");
const getFrontendUrl = require("../utils/frontendUrl");
const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"];
@@ -33,7 +36,6 @@ function computeAgeStats(users) {
const getStats = async (req, res) => {
try {
await expireOverdueClaims();
const gameWindow = await getGameWindow();
const [
@@ -79,9 +81,11 @@ const getStats = async (req, res) => {
ticketsUsed,
ticketsRemaining: ticketsTotal - ticketsUsed,
participations,
claimsPending,
// L'expiration n'est pas écrite en base : une fois la fenêtre de retrait
// fermée, les PENDING restants comptent comme annulés.
claimsPending: gameWindow.isOpen ? claimsPending : 0,
claimsDelivered,
claimsCancelled,
claimsCancelled: gameWindow.isOpen ? claimsCancelled : claimsCancelled + claimsPending,
users,
gameWindow,
ageStats: computeAgeStats(clientsWithBirthdate),
@@ -163,9 +167,30 @@ const getEmailingContacts = async (req, res) => {
}
};
// Sans caractères ambigus (0/O, 1/l/I) : l'employé peut devoir le recopier.
const TEMP_PASSWORD_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
// Aucun caractère à échapper dans l'email HTML (&, <, >, guillemets).
const TEMP_PASSWORD_SPECIALS = "!@#$%*?";
const TEMP_PASSWORD_LENGTH = 12;
// Respecte la règle commune des mots de passe (au moins un caractère spécial),
// placé à une position aléatoire.
function generateTempPassword() {
const chars = [];
for (let i = 0; i < TEMP_PASSWORD_LENGTH - 1; i++) {
chars.push(TEMP_PASSWORD_ALPHABET[crypto.randomInt(TEMP_PASSWORD_ALPHABET.length)]);
}
const special = TEMP_PASSWORD_SPECIALS[crypto.randomInt(TEMP_PASSWORD_SPECIALS.length)];
chars.splice(crypto.randomInt(chars.length + 1), 0, special);
return chars.join("");
}
// L'admin ne choisit pas le mot de passe de l'employé : on en génère un
// provisoire, envoyé uniquement par email, que l'employé devra remplacer à
// sa première connexion (mustChangePassword).
const createEmployee = async (req, res) => {
try {
const { firstname, lastname, email, password, birthdate, gender } = req.body;
const { firstname, lastname, email, birthdate, gender } = req.body;
const existingUser = await prisma.user.findUnique({
where: { email },
@@ -177,7 +202,8 @@ const createEmployee = async (req, res) => {
});
}
const hashedPassword = await bcrypt.hash(password, 10);
const tempPassword = generateTempPassword();
const hashedPassword = await bcrypt.hash(tempPassword, 10);
const employee = await prisma.user.create({
data: {
@@ -189,6 +215,8 @@ const createEmployee = async (req, res) => {
gender,
role: "EMPLOYEE",
marketingOptIn: false,
isEmailVerified: true,
mustChangePassword: true,
},
select: {
id: true,
@@ -200,8 +228,45 @@ const createEmployee = async (req, res) => {
},
});
const loginUrl = `${getFrontendUrl()}/connexion`;
try {
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: email,
subject: "Votre compte employé Thé Tip Top",
html: `
<div style="font-family:sans-serif;max-width:480px;margin:auto">
<h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
<p>Un compte employé Thé Tip Top vient d'être créé pour vous.</p>
<p>Voici vos identifiants de première connexion :</p>
<p style="background:#f3f4f6;padding:12px 16px;border-radius:8px">
Email : <strong>${email}</strong><br />
Mot de passe provisoire : <strong style="font-family:monospace;font-size:16px">${tempPassword}</strong>
</p>
<p style="text-align:center;padding:20px 0">
<a href="${loginUrl}" style="background:#006B6B;color:#fff;text-decoration:none;padding:12px 24px;border-radius:8px;display:inline-block">
Me connecter
</a>
</p>
<p style="color:#888;font-size:13px">Dès votre première connexion, vous devrez
choisir un nouveau mot de passe. Le mot de passe provisoire ne fonctionnera
plus ensuite.</p>
</div>
`,
});
} catch (mailError) {
// Sans l'email, l'employé n'a aucun moyen de connaître son mot de passe :
// on annule la création pour que l'admin puisse simplement réessayer.
console.error("Erreur envoi email employé:", mailError);
await prisma.user.delete({ where: { id: employee.id } });
return res.status(502).json({
message: "L'email d'invitation n'a pas pu être envoyé : le compte n'a pas été créé. Réessayez.",
});
}
return res.status(201).json({
message: "Employé créé avec succès.",
message: "Employé créé. Un email avec un mot de passe provisoire lui a été envoyé.",
employee,
});
} catch (error) {
@@ -214,6 +279,31 @@ const createEmployee = async (req, res) => {
const drawWinner = async (req, res) => {
try {
// Le grand gagnant est tiré parmi tous les participants : le tirage n'a de
// sens qu'une fois la période de participation close. Avant l'ouverture,
// il n'existe aucun participant à départager.
const gameWindow = await getGameWindow();
if (!gameWindow.configured) {
return res.status(409).json({
message: "Le jeu-concours n'a pas encore été lancé : aucun tirage au sort n'est possible.",
});
}
if (!gameWindow.hasStarted) {
return res.status(409).json({
message: `Le jeu-concours n'a pas encore commencé. Il ouvre le ${gameWindow.startDate.toLocaleDateString("fr-FR")}.`,
startDate: gameWindow.startDate,
});
}
if (gameWindow.isParticipationOpen) {
return res.status(409).json({
message: `Le tirage au sort ne peut avoir lieu qu'après la fin des participations, le ${gameWindow.participationDeadline.toLocaleDateString("fr-FR")}.`,
participationDeadline: gameWindow.participationDeadline,
});
}
const existingWinner = await prisma.grandPrizeWinner.findFirst({
include: {
user: {
@@ -234,6 +324,10 @@ const drawWinner = async (req, res) => {
});
}
// Un utilisateur = une entrée dans le pool, quel que soit son nombre de
// participations. On sélectionne des CLIENT distincts (pas des
// participations), donc avoir utilisé 10 tickets ou 1 seul ne change
// strictement rien aux chances de gagner le gros lot.
const participants = await prisma.user.findMany({
where: {
role: "CLIENT",
@@ -255,7 +349,11 @@ const drawWinner = async (req, res) => {
});
}
const randomIndex = Math.floor(Math.random() * participants.length);
// Tirage purement aléatoire et équiprobable parmi les participants
// distincts. crypto.randomInt (générateur cryptographiquement sûr,
// sans biais) plutôt que Math.random(), pour un tirage défendable
// devant l'huissier qui le supervise.
const randomIndex = crypto.randomInt(participants.length);
const winner = participants[randomIndex];
const savedWinner = await prisma.grandPrizeWinner.create({
@@ -274,9 +372,41 @@ const drawWinner = async (req, res) => {
},
});
// Le tirage est un acte irréversible réalisé sous contrôle d'huissier :
// un email qui ne part pas ne doit surtout pas l'invalider. On signale
// simplement l'échec pour que l'administrateur prévienne autrement.
let emailSent = true;
try {
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: savedWinner.user.email,
subject: "Vous avez gagné le gros lot Thé Tip Top !",
html: `
<div style="font-family:sans-serif;max-width:480px;margin:auto">
<h2 style="color:#006B6B">Félicitations ${savedWinner.user.firstname} !</h2>
<p>Le tirage au sort du jeu-concours « 100% Gagnant » vient d'avoir lieu,
et c'est vous qui remportez le gros lot :</p>
<div style="font-size:20px;font-weight:bold;color:#006B6B;text-align:center;padding:20px;background:#F0F0F0;border-radius:12px">
${savedWinner.prize}
</div>
<p>Notre équipe vous recontactera très prochainement pour convenir des
modalités de remise de votre lot.</p>
<p style="color:#888;font-size:13px">Merci de votre participation et à bientôt chez Thé Tip Top.</p>
</div>
`,
});
} catch (mailError) {
emailSent = false;
console.error("Erreur envoi email au grand gagnant:", mailError);
}
return res.status(201).json({
message: "Tirage au sort effectué avec succès.",
message: emailSent
? "Tirage au sort effectué avec succès. Le gagnant a été prévenu par email."
: "Tirage au sort effectué avec succès, mais l'email n'a pas pu être envoyé au gagnant. Merci de le contacter directement.",
totalEligibleParticipants: participants.length,
emailSent,
winner: savedWinner,
});
} catch (error) {
@@ -287,6 +417,52 @@ const drawWinner = async (req, res) => {
}
};
// Lancement du jeu-concours : fixe (ou corrige) la date de début, dont
// découlent les deux échéances. Une seule ligne de configuration existe, on
// la met donc à jour si elle est déjà présente.
const setGameSettings = async (req, res) => {
try {
const { startDate } = req.body;
const parsedStartDate = new Date(startDate);
const existing = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
if (existing) {
await prisma.gameSettings.update({
where: { id: existing.id },
data: { startDate: parsedStartDate },
});
} else {
await prisma.gameSettings.create({ data: { startDate: parsedStartDate } });
}
const gameWindow = await getGameWindow();
return res.json({
message: existing
? "Dates du jeu-concours mises à jour."
: "Jeu-concours lancé avec succès.",
gameWindow,
});
} catch (error) {
console.error("Erreur configuration du jeu:", error);
return res.status(500).json({
message: "Erreur serveur lors de la configuration du jeu-concours.",
});
}
};
const getGameSettings = async (req, res) => {
try {
return res.json({ gameWindow: await getGameWindow() });
} catch (error) {
console.error("Erreur lecture configuration du jeu:", error);
return res.status(500).json({
message: "Erreur serveur lors de la lecture de la configuration.",
});
}
};
const getGrandPrizeWinner = async (req, res) => {
try {
const winner = await prisma.grandPrizeWinner.findFirst({
@@ -302,14 +478,8 @@ const getGrandPrizeWinner = async (req, res) => {
},
});
if (!winner) {
return res.status(404).json({
message: "Aucun gagnant du gros lot n'a encore été tiré.",
});
}
return res.json({
winner,
winner: winner ?? null,
});
} catch (error) {
console.error("Erreur récupération gagnant:", error);
@@ -319,11 +489,139 @@ const getGrandPrizeWinner = async (req, res) => {
}
};
const PERSON_SELECT = { id: true, firstname: true, lastname: true, email: true };
// Comptes employés, avec le nombre de lots que chacun a remis. `q` filtre sur
// le prénom, le nom ou l'email.
const getEmployees = async (req, res) => {
try {
const q = typeof req.query.q === "string" ? req.query.q.trim() : "";
const contains = { contains: q, mode: "insensitive" };
const employees = await prisma.user.findMany({
where: {
role: "EMPLOYEE",
...(q && { OR: [{ firstname: contains }, { lastname: contains }, { email: contains }] }),
},
select: {
...PERSON_SELECT,
createdAt: true,
mustChangePassword: true,
_count: { select: { claimsValidated: { where: { status: "DELIVERED" } } } },
},
orderBy: [{ lastname: "asc" }, { firstname: "asc" }],
});
return res.json({
employees: employees.map(({ _count, ...employee }) => ({
...employee,
deliveredCount: _count.claimsValidated,
})),
});
} catch (error) {
console.error("Erreur liste employés:", error);
return res.status(500).json({ message: "Erreur serveur lors de la récupération des employés." });
}
};
// Lots remis par un employé, du plus récent au plus ancien.
const getEmployeeDeliveries = async (req, res) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
return res.status(400).json({ message: "Identifiant d'employé invalide." });
}
const employee = await prisma.user.findFirst({
where: { id, role: "EMPLOYEE" },
select: PERSON_SELECT,
});
if (!employee) {
return res.status(404).json({ message: "Employé introuvable." });
}
const claims = await prisma.claim.findMany({
where: { validatedByEmployeeId: id, status: "DELIVERED" },
orderBy: { claimedAt: "desc" },
include: {
participation: {
include: {
ticket: { select: { code: true } },
lot: { select: { name: true, value: true } },
user: { select: PERSON_SELECT },
},
},
},
});
return res.json({
employee,
deliveries: claims.map((claim) => ({
claimId: claim.id,
ticketCode: claim.participation.ticket.code,
lot: claim.participation.lot,
client: claim.participation.user,
deliveredAt: claim.claimedAt,
})),
});
} catch (error) {
console.error("Erreur remises employé:", error);
return res.status(500).json({ message: "Erreur serveur lors de la récupération des remises." });
}
};
// Traçabilité d'un ticket : qui l'a joué, quel lot, et quel employé l'a remis
// et quand. `status` vaut NOT_PLAYED tant qu'aucun client ne l'a saisi.
const getDeliveryByTicketCode = async (req, res) => {
try {
const code = req.params.code.trim().toUpperCase();
const gameWindow = await getGameWindow();
const ticket = await prisma.ticket.findUnique({
where: { code },
include: {
lot: { select: { name: true, value: true } },
participation: {
include: {
user: { select: PERSON_SELECT },
claim: { include: { validatedByEmployee: { select: PERSON_SELECT } } },
},
},
},
});
if (!ticket) {
return res.status(404).json({ message: "Aucun ticket ne correspond à ce numéro." });
}
const claim = ticket.participation?.claim;
return res.json({
ticketCode: ticket.code,
lot: ticket.lot,
status: claim ? effectiveClaimStatus(claim.status, gameWindow) : "NOT_PLAYED",
playedAt: ticket.usedAt,
client: ticket.participation?.user ?? null,
deliveredBy: claim?.validatedByEmployee ?? null,
deliveredAt: claim?.status === "DELIVERED" ? claim.claimedAt : null,
});
} catch (error) {
console.error("Erreur recherche remise:", error);
return res.status(500).json({ message: "Erreur serveur lors de la recherche du ticket." });
}
};
module.exports = {
getStats,
getUsers,
getEmailingContacts,
createEmployee,
getEmployees,
getEmployeeDeliveries,
getDeliveryByTicketCode,
drawWinner,
getGrandPrizeWinner,
setGameSettings,
getGameSettings,
};

View File

@@ -3,6 +3,9 @@ const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const prisma = require("../config/prisma");
const transporter = require("../config/mailer");
const getFrontendUrl = require("../utils/frontendUrl");
const { passwordRuleError } = require("../validations/password.validation");
const { normalizeEmail } = require("../validations/email.validation");
const register = async (req, res) => {
try {
@@ -62,11 +65,17 @@ const register = async (req, res) => {
},
});
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: email,
subject: "Votre code de validation Thé Tip Top",
html: `
// Le code de validation est le seul moyen d'activer le compte. Si l'envoi
// échoue, laisser l'utilisateur en base le bloquerait définitivement : il
// ne pourrait ni se connecter (email non vérifié) ni se réinscrire (email
// déjà pris). On annule donc la création pour que la tentative reste
// rejouable.
try {
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: email,
subject: "Votre code de validation Thé Tip Top",
html: `
<div style="font-family:sans-serif;max-width:480px;margin:auto">
<h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
<p>Merci pour votre inscription sur Thé Tip Top.</p>
@@ -77,7 +86,21 @@ const register = async (req, res) => {
<p style="color:#888;font-size:13px">Ce code expire dans <strong>15 minutes</strong>.</p>
</div>
`,
});
});
} catch (mailError) {
console.error("Erreur envoi du code de validation:", mailError);
try {
await prisma.user.delete({ where: { id: user.id } });
} catch (rollbackError) {
console.error("Echec de l'annulation de l'inscription:", rollbackError);
}
return res.status(502).json({
message:
"Votre compte n'a pas pu être créé : l'email de validation n'a pas pu être envoyé. Merci de réessayer dans quelques instants.",
});
}
return res.status(201).json({
message: "Inscription réussie. Un code de validation a été envoyé par email.",
@@ -156,6 +179,14 @@ const login = async (req, res) => {
});
}
// Compte créé via un provider externe : aucun mot de passe à comparer.
if (!user.password) {
const providerLabel = user.provider === "FACEBOOK" ? "Facebook" : "Google";
return res.status(401).json({
message: `Ce compte utilise la connexion ${providerLabel}. Utilisez le bouton « ${providerLabel} » pour vous connecter.`,
});
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
@@ -182,6 +213,7 @@ const login = async (req, res) => {
lastname: user.lastname,
email: user.email,
role: user.role,
mustChangePassword: user.mustChangePassword,
},
});
} catch (error) {
@@ -192,9 +224,47 @@ const login = async (req, res) => {
}
};
const RESET_LINK_EXPIRY_MS = 1000 * 60 * 30; // 30 min
// Enregistre un jeton de réinitialisation (qui remplace le précédent) et envoie
// le lien par email. Partagé par « mot de passe oublié » et par le profil.
async function sendPasswordResetEmail(user) {
const resetPasswordToken = crypto.randomBytes(32).toString("hex");
await prisma.user.update({
where: { id: user.id },
data: {
resetPasswordToken,
resetPasswordExpires: new Date(Date.now() + RESET_LINK_EXPIRY_MS),
},
});
// Le lien doit ouvrir le formulaire du front, pas la route POST de l'API :
// un clic depuis un email est une requête GET, qui tombait en 404.
const resetUrl = `${getFrontendUrl()}/definir-mot-de-passe/${resetPasswordToken}`;
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: user.email,
subject: "Réinitialisation de votre mot de passe Thé Tip Top",
html: `
<h2>Réinitialisation du mot de passe</h2>
<p>Bonjour ${user.firstname},</p>
<p>Vous avez demandé à définir un nouveau mot de passe.</p>
<p>Cliquez sur le lien ci-dessous pour le choisir :</p>
<a href="${resetUrl}">Choisir mon nouveau mot de passe</a>
<p>Ce lien expire dans 30 minutes.</p>
<p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p>
`,
});
}
const FORGOT_PASSWORD_MESSAGE =
"Si un compte existe avec cet email, un lien de réinitialisation vient de lui être envoyé.";
const forgotPassword = async (req, res) => {
try {
const { email } = req.body;
const email = normalizeEmail(req.body.email);
if (!email) {
return res.status(400).json({
@@ -208,42 +278,12 @@ const forgotPassword = async (req, res) => {
// Sécurité : ne pas dire si l'email existe ou non
if (!user) {
return res.status(200).json({
message: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.",
});
return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE });
}
const resetPasswordToken = crypto.randomBytes(32).toString("hex");
const resetPasswordExpires = new Date(Date.now() + 1000 * 60 * 30); // 30 min
await sendPasswordResetEmail(user);
await prisma.user.update({
where: { id: user.id },
data: {
resetPasswordToken,
resetPasswordExpires,
},
});
const resetUrl = `${process.env.APP_URL}/api/auth/reset-password/${resetPasswordToken}`;
await transporter.sendMail({
from: process.env.MAIL_FROM,
to: user.email,
subject: "Réinitialisation de votre mot de passe Thé Tip Top",
html: `
<h2>Réinitialisation du mot de passe</h2>
<p>Bonjour ${user.firstname},</p>
<p>Vous avez demandé la réinitialisation de votre mot de passe.</p>
<p>Cliquez sur le lien ci-dessous pour définir un nouveau mot de passe :</p>
<a href="${resetUrl}">Réinitialiser mon mot de passe</a>
<p>Ce lien expire dans 30 minutes.</p>
<p>Si vous n'êtes pas à l'origine de cette demande, ignorez cet email.</p>
`,
});
return res.status(200).json({
message: "Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.",
});
return res.status(200).json({ message: FORGOT_PASSWORD_MESSAGE });
} catch (error) {
console.error("Erreur forgotPassword:", error);
return res.status(500).json({
@@ -252,14 +292,34 @@ const forgotPassword = async (req, res) => {
}
};
// Profil connecté (compte Google sans mot de passe) : même lien, envoyé à
// l'adresse du compte. Authentifié, donc sans captcha.
const sendPasswordSetupLink = async (req, res) => {
try {
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
await sendPasswordResetEmail(user);
return res.json({ message: `Lien envoyé à ${user.email}.` });
} catch (error) {
console.error("Erreur sendPasswordSetupLink:", error);
return res.status(500).json({ message: "Erreur serveur lors de l'envoi du lien." });
}
};
const resetPassword = async (req, res) => {
try {
const { token } = req.params;
const { password } = req.body;
const { password, confirmPassword } = req.body;
if (!password || password.length < 8) {
const passwordError = passwordRuleError(password);
if (passwordError) {
return res.status(400).json({ message: passwordError });
}
// La confirmation est vérifiée côté serveur aussi : le contrôle du
// navigateur ne protège pas un appel direct à l'API.
if (confirmPassword !== undefined && password !== confirmPassword) {
return res.status(400).json({
message: "Le nouveau mot de passe doit contenir au moins 8 caractères.",
message: "Les deux mots de passe ne correspondent pas.",
});
}
@@ -286,6 +346,14 @@ const resetPassword = async (req, res) => {
password: hashedPassword,
resetPasswordToken: null,
resetPasswordExpires: null,
// Le lien reçu par email prouve l'accès à la boîte : le mot de passe
// choisi ici remplace aussi un éventuel mot de passe provisoire, et
// l'adresse est considérée comme validée (sinon la connexion qui suit
// serait refusée pour email non vérifié).
mustChangePassword: false,
isEmailVerified: true,
emailVerifyToken: null,
emailVerifyExpires: null,
},
});
@@ -302,7 +370,8 @@ const resetPassword = async (req, res) => {
const verifyCode = async (req, res) => {
try {
const { email, code } = req.body;
const { code } = req.body;
const email = normalizeEmail(req.body.email);
if (!email || !code) {
return res.status(400).json({ message: "Email et code requis." });
@@ -354,6 +423,55 @@ const verifyCode = async (req, res) => {
}
};
// Fin du parcours OAuth : passport a posé l'utilisateur sur req.user, on émet
// le même JWT que la connexion classique et on renvoie le navigateur vers le
// front, qui récupère le token dans l'URL.
const oauthCallback = (req, res) => {
const frontendUrl = getFrontendUrl();
try {
const user = req.user;
if (!user) {
return res.redirect(
`${frontendUrl}/auth/callback?error=${encodeURIComponent(
"Connexion externe refusée."
)}`
);
}
const token = jwt.sign(
{ id: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "24h" }
);
const payload = Buffer.from(
JSON.stringify({
id: user.id,
firstname: user.firstname,
lastname: user.lastname,
email: user.email,
role: user.role,
mustChangePassword: user.mustChangePassword,
})
).toString("base64");
// encodeURIComponent : le base64 peut contenir des « + » que le front
// relirait comme des espaces dans la query string.
return res.redirect(
`${frontendUrl}/auth/callback?token=${token}&user=${encodeURIComponent(payload)}`
);
} catch (error) {
console.error("Erreur oauthCallback:", error);
return res.redirect(
`${frontendUrl}/auth/callback?error=${encodeURIComponent(
"Erreur serveur lors de la connexion externe."
)}`
);
}
};
const me = async (req, res) => {
return res.json({
user: req.user,
@@ -383,10 +501,13 @@ const deleteAccount = async (req, res) => {
// 4. Supprimer les participations
await tx.participation.deleteMany({ where: { userId } });
// 5. Libérer les tickets utilisés par ce user
// 5. Détacher les tickets du compte sans les remettre en jeu : un code
// figure sur un ticket de caisse physique et n'est jouable qu'une
// fois. Le réinitialiser permettrait de rejouer indéfiniment le même
// code en supprimant puis recréant un compte.
await tx.ticket.updateMany({
where: { userId },
data: { userId: null, isUsed: false, usedAt: null },
data: { userId: null },
});
// 6. Supprimer l'utilisateur
@@ -400,6 +521,47 @@ const deleteAccount = async (req, res) => {
}
};
// Première connexion d'un employé : remplace le mot de passe provisoire reçu
// par email. L'utilisateur vient de s'authentifier avec ce mot de passe, on ne
// le redemande donc pas.
const setInitialPassword = async (req, res) => {
try {
const { password, confirmPassword } = req.body;
if (!req.user.mustChangePassword) {
return res.status(400).json({ message: "Votre mot de passe a déjà été défini." });
}
const passwordError = passwordRuleError(password);
if (passwordError) {
return res.status(400).json({ message: passwordError });
}
if (password !== confirmPassword) {
return res.status(400).json({ message: "Les deux mots de passe ne correspondent pas." });
}
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
if (user.password && (await bcrypt.compare(password, user.password))) {
return res.status(400).json({
message: "Choisissez un mot de passe différent du mot de passe provisoire.",
});
}
const hashed = await bcrypt.hash(password, 10);
await prisma.user.update({
where: { id: user.id },
data: { password: hashed, mustChangePassword: false },
});
return res.json({ message: "Mot de passe enregistré." });
} catch (error) {
console.error("Erreur setInitialPassword:", error);
return res.status(500).json({ message: "Erreur serveur lors de l'enregistrement du mot de passe." });
}
};
const changePassword = async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
@@ -409,12 +571,20 @@ const changePassword = async (req, res) => {
return res.status(400).json({ message: "Les deux mots de passe sont requis." });
}
if (newPassword.length < 8) {
return res.status(400).json({ message: "Le nouveau mot de passe doit faire au moins 8 caractères." });
const passwordError = passwordRuleError(newPassword);
if (passwordError) {
return res.status(400).json({ message: passwordError });
}
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user.password) {
return res.status(400).json({
message:
"Ce compte utilise une connexion externe et n'a pas de mot de passe à modifier.",
});
}
const isValid = await bcrypt.compare(currentPassword, user.password);
if (!isValid) {
return res.status(401).json({ message: "Mot de passe actuel incorrect." });
@@ -438,6 +608,9 @@ module.exports = {
verifyCode,
resetPassword,
forgotPassword,
sendPasswordSetupLink,
changePassword,
setInitialPassword,
deleteAccount,
oauthCallback,
};

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
const jwt = require("jsonwebtoken");
const prisma = require("../config/prisma");
// Seules routes accessibles tant qu'un mot de passe provisoire n'a pas été
// remplacé : sans ce filtre côté serveur, il suffirait d'appeler l'API
// directement pour contourner la page du front.
const ALLOWED_WHILE_PASSWORD_CHANGE_REQUIRED = ["/api/auth/me", "/api/auth/initial-password"];
const authMiddleware = async (req, res, next) => {
try {
const authHeader = req.headers.authorization;
@@ -23,6 +28,9 @@ const authMiddleware = async (req, res, next) => {
lastname: true,
email: true,
role: true,
provider: true,
password: true,
mustChangePassword: true,
},
});
@@ -32,7 +40,19 @@ const authMiddleware = async (req, res, next) => {
});
}
req.user = user;
const route = `${req.baseUrl}${req.path}`.replace(/\/+$/, "");
if (user.mustChangePassword && !ALLOWED_WHILE_PASSWORD_CHANGE_REQUIRED.includes(route)) {
return res.status(403).json({
message: "Vous devez choisir un nouveau mot de passe avant de continuer.",
code: "PASSWORD_CHANGE_REQUIRED",
});
}
// req.user est renvoyé tel quel par /api/auth/me : on remplace le hash par
// un simple booléen, qui suffit au profil pour proposer « définir » ou
// « modifier » son mot de passe.
const { password, ...rest } = user;
req.user = { ...rest, hasPassword: Boolean(password) };
next();
} catch (error) {
return res.status(401).json({

View 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;

View File

@@ -4,14 +4,19 @@ const {
getUsers,
getEmailingContacts,
createEmployee,
getEmployees,
getEmployeeDeliveries,
getDeliveryByTicketCode,
drawWinner,
getGrandPrizeWinner,
setGameSettings,
getGameSettings,
} = require("../controllers/admin.controller");
const authMiddleware = require("../middlewares/auth.middleware");
const roleMiddleware = require("../middlewares/role.middleware");
const validate = require("../middlewares/validate.middleware");
const { createEmployeeSchema } = require("../validations/admin.validation");
const { createEmployeeSchema, gameSettingsSchema } = require("../validations/admin.validation");
const router = express.Router();
@@ -79,6 +84,73 @@ router.get("/emailing", getEmailingContacts);
*/
router.post("/employees", validate(createEmployeeSchema), createEmployee);
/**
* @swagger
* /api/admin/employees:
* get:
* summary: Liste des comptes employés avec leur nombre de lots remis
* tags:
* - Admin
* security:
* - bearerAuth: []
* parameters:
* - in: query
* name: q
* schema:
* type: string
* description: Filtre sur le prénom, le nom ou l'email
* responses:
* 200:
* description: Employés
*/
router.get("/employees", getEmployees);
/**
* @swagger
* /api/admin/employees/{id}/deliveries:
* get:
* summary: Lots remis par un employé
* tags:
* - Admin
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Employé et liste de ses remises (ticket, lot, client, date)
* 404:
* description: Employé introuvable
*/
router.get("/employees/:id/deliveries", getEmployeeDeliveries);
/**
* @swagger
* /api/admin/deliveries/{code}:
* get:
* summary: Traçabilité d'un ticket (lot, client, employé ayant remis le lot, date)
* tags:
* - Admin
* security:
* - bearerAuth: []
* parameters:
* - in: path
* name: code
* required: true
* schema:
* type: string
* responses:
* 200:
* description: Statut du ticket (NOT_PLAYED, PENDING, DELIVERED, CANCELLED) et remise éventuelle
* 404:
* description: Aucun ticket ne correspond à ce numéro
*/
router.get("/deliveries/:code", getDeliveryByTicketCode);
/**
* @swagger
* /api/admin/draw:
@@ -105,10 +177,48 @@ router.post("/draw", drawWinner);
* - bearerAuth: []
* responses:
* 200:
* description: Gagnant du gros lot
* 404:
* description: Aucun gagnant encore tiré
* description: Gagnant du gros lot, ou { winner - null } si aucun tirage n'a encore eu lieu
*/
router.get("/draw", getGrandPrizeWinner);
/**
* @swagger
* /api/admin/game-settings:
* get:
* summary: Fenêtre du jeu-concours (début, fin des participations, fin des retraits)
* tags:
* - Admin
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Configuration courante du jeu-concours
* put:
* summary: Lance le jeu-concours ou corrige sa date de début
* tags:
* - Admin
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - startDate
* properties:
* startDate:
* type: string
* format: date-time
* example: 2026-09-01T00:00:00.000Z
* responses:
* 200:
* description: Jeu-concours lancé ou mis à jour
* 400:
* description: Date de début invalide
*/
router.get("/game-settings", getGameSettings);
router.put("/game-settings", validate(gameSettingsSchema), setGameSettings);
module.exports = router;

View File

@@ -1,8 +1,11 @@
const express = require("express");
const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount } = require("../controllers/auth.controller");
const { register, login, verifyEmail, verifyCode, forgotPassword, sendPasswordSetupLink, resetPassword, me, changePassword, setInitialPassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller");
const authMiddleware = require("../middlewares/auth.middleware");
const validate = require("../middlewares/validate.middleware");
const verifyCaptcha = require("../middlewares/captcha.middleware");
const { registerSchema, loginSchema } = require("../validations/auth.validation");
const { passport, googleEnabled, facebookEnabled } = require("../config/passport");
const getFrontendUrl = require("../utils/frontendUrl");
const router = express.Router();
@@ -32,7 +35,7 @@ const router = express.Router();
* 201:
* description: Utilisateur créé
*/
router.post("/register", validate(registerSchema), register);
router.post("/register", verifyCaptcha, validate(registerSchema), register);
/**
* @swagger
@@ -48,7 +51,7 @@ router.post("/register", validate(registerSchema), register);
* description: Connexion réussie
*/
router.post("/login", validate(loginSchema), login);
router.post("/login", verifyCaptcha, validate(loginSchema), login);
/**
* @swagger
@@ -110,16 +113,36 @@ router.get("/verify-email/:token",verifyEmail);
* type: string
* format: email
* example: jean.dupont@gmail.com
* captchaToken:
* type: string
* description: Jeton Cloudflare Turnstile (exigé si TURNSTILE_SECRET_KEY est configurée)
* responses:
* 200:
* description: Si un compte existe avec cet email, un lien de réinitialisation a été envoyé.
* 400:
* description: Email requis.
* description: Email requis, ou vérification anti-robot manquante.
* 500:
* description: Erreur serveur.
*/
router.post("/forgot-password", forgotPassword);
// Page publique : le captcha empêche d'envoyer des emails en masse vers
// n'importe quelle adresse.
router.post("/forgot-password", verifyCaptcha, forgotPassword);
/**
* @swagger
* /api/auth/password-setup-link:
* post:
* summary: Envoie à l'utilisateur connecté un lien pour définir son mot de passe
* tags:
* - Auth
* security:
* - bearerAuth: []
* responses:
* 200:
* description: Lien envoyé à l'adresse du compte.
*/
router.post("/password-setup-link", authMiddleware, sendPasswordSetupLink);
/**
* @swagger
* /api/auth/reset-password/{token}:
@@ -159,6 +182,131 @@ router.post("/reset-password/:token", resetPassword);
router.post("/verify-code", verifyCode);
router.patch("/change-password", authMiddleware, changePassword);
/**
* @swagger
* /api/auth/initial-password:
* post:
* summary: Remplace le mot de passe provisoire d'un employé à sa première connexion
* tags:
* - Auth
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required:
* - password
* - confirmPassword
* properties:
* password:
* type: string
* confirmPassword:
* type: string
* responses:
* 200:
* description: Mot de passe enregistré, l'accès complet est rétabli.
* 400:
* description: Mot de passe invalide, non confirmé, identique au provisoire ou déjà défini.
*/
router.post("/initial-password", authMiddleware, setInitialPassword);
router.delete("/account", authMiddleware, deleteAccount);
/**
* @swagger
* /api/auth/google:
* get:
* summary: Démarre la connexion via Google (redirection OAuth 2.0)
* tags:
* - Auth
* responses:
* 302:
* description: Redirection vers l'écran de consentement Google.
* 503:
* description: Connexion Google non configurée sur ce serveur.
*/
/**
* @swagger
* /api/auth/google/callback:
* get:
* summary: Retour de Google, émet le JWT et redirige vers le front
* tags:
* - Auth
* responses:
* 302:
* description: Redirection vers FRONTEND_URL/auth/callback avec le token.
*/
/**
* @swagger
* /api/auth/facebook:
* get:
* summary: Démarre la connexion via Facebook (redirection OAuth 2.0)
* tags:
* - Auth
* responses:
* 302:
* description: Redirection vers l'écran de consentement Facebook.
* 503:
* description: Connexion Facebook non configurée sur ce serveur.
*/
/**
* @swagger
* /api/auth/facebook/callback:
* get:
* summary: Retour de Facebook, émet le JWT et redirige vers le front
* tags:
* - Auth
* responses:
* 302:
* description: Redirection vers FRONTEND_URL/auth/callback avec le token.
*/
function mountOAuthRoutes({ path, strategy, label, enabled, scope }) {
if (!enabled) {
// Sans identifiants configurés, on répond explicitement plutôt que de
// laisser passport planter au démarrage.
const notConfigured = (req, res) =>
res.status(503).json({ message: `La connexion ${label} n'est pas configurée sur ce serveur.` });
router.get(path, notConfigured);
router.get(`${path}/callback`, notConfigured);
return;
}
router.get(path, passport.authenticate(strategy, { scope, session: false }));
router.get(
`${path}/callback`,
passport.authenticate(strategy, {
session: false,
failureRedirect: `${getFrontendUrl()}/auth/callback?error=${encodeURIComponent(
`Connexion ${label} refusée.`
)}`,
}),
oauthCallback
);
}
mountOAuthRoutes({
path: "/google",
strategy: "google",
label: "Google",
enabled: googleEnabled,
scope: ["profile", "email"],
});
mountOAuthRoutes({
path: "/facebook",
strategy: "facebook",
label: "Facebook",
enabled: facebookEnabled,
scope: ["email"],
});
module.exports = router;

View File

@@ -36,7 +36,7 @@ const router = express.Router();
router.get(
"/ticket/:code",
authMiddleware,
roleMiddleware("EMPLOYEE", "ADMIN"),
roleMiddleware("EMPLOYEE"),
getGainByTicketCode
);
@@ -69,7 +69,7 @@ router.get(
router.patch(
"/claim/:id/deliver",
authMiddleware,
roleMiddleware("EMPLOYEE", "ADMIN"),
roleMiddleware("EMPLOYEE"),
deliverClaim
);

View File

@@ -1,6 +1,7 @@
const express = require("express");
const { participate, myGains } = require("../controllers/game.controller");
const { participate, myGains, getPublicGameWindow } = require("../controllers/game.controller");
const authMiddleware = require("../middlewares/auth.middleware");
const roleMiddleware = require("../middlewares/role.middleware");
const validate = require("../middlewares/validate.middleware");
const { participateSchema } = require("../validations/game.validation");
@@ -20,7 +21,9 @@ const router = express.Router();
* description: Participation enregistrée
*/
router.post("/participate", authMiddleware, validate(participateSchema), participate);
// Le jeu est reserve aux clients : un ADMIN ou un EMPLOYEE qui joue
// consommerait des tickets et fausserait les statistiques.
router.post("/participate", authMiddleware, roleMiddleware("CLIENT"), validate(participateSchema), participate);
/**
* @swagger
@@ -36,6 +39,19 @@ router.post("/participate", authMiddleware, validate(participateSchema), partici
* description: Liste des gains
*/
router.get("/my-gains", authMiddleware, myGains);
router.get("/my-gains", authMiddleware, roleMiddleware("CLIENT"), myGains);
/**
* @swagger
* /api/game/window:
* get:
* summary: Dates publiques du jeu-concours (début, fin des participations, fin des retraits)
* tags:
* - Game
* responses:
* 200:
* description: Phase et échéances courantes ; dates nulles si le jeu n'est pas configuré
*/
router.get("/window", getPublicGameWindow);
module.exports = router;

View File

@@ -1,9 +1,15 @@
require('dotenv').config()
const bcrypt = require('bcrypt')
const prisma = require('../config/prisma')
async function createAdmin() {
const email = 'admin@thetiptop.fr'
const password = 'Admin@TipTop2026!'
const email = process.env.ADMIN_EMAIL
const password = process.env.ADMIN_PASSWORD
if (!email || !password) {
throw new Error('ADMIN_EMAIL et ADMIN_PASSWORD doivent être définis')
}
const existing = await prisma.user.findUnique({ where: { email } })

View 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;

View File

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

View File

@@ -1,14 +1,22 @@
const { z } = require("zod");
const { emailSchema } = require("./email.validation");
const createEmployeeSchema = z.object({
firstname: z.string().min(2),
lastname: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
// Pas de mot de passe : il est généré côté serveur (voir createEmployee).
email: emailSchema,
birthdate: z.string().optional(),
gender: z.string().optional(),
});
const gameSettingsSchema = z.object({
startDate: z
.string()
.refine((value) => !Number.isNaN(new Date(value).getTime()), "Date de début invalide"),
});
module.exports = {
createEmployeeSchema,
gameSettingsSchema,
};

View File

@@ -1,17 +1,27 @@
const { z } = require("zod");
const { passwordSchema } = require("./password.validation");
const { emailSchema } = require("./email.validation");
const registerSchema = z.object({
firstname: z.string().min(2, "Le prénom doit contenir au moins 2 caractères"),
lastname: z.string().min(2, "Le nom doit contenir au moins 2 caractères"),
email: z.string().email("Email invalide"),
password: z.string().min(8, "Le mot de passe doit contenir au moins 8 caractères"),
birthdate: z.string().optional(),
gender: z.string().optional(),
marketingOptIn: z.boolean().optional(),
});
const registerSchema = z
.object({
firstname: z.string().min(2, "Le prénom doit contenir au moins 2 caractères"),
lastname: z.string().min(2, "Le nom doit contenir au moins 2 caractères"),
email: emailSchema,
password: passwordSchema,
// Vérifiée aussi côté serveur : le contrôle du navigateur ne protège pas
// un appel direct à l'API.
confirmPassword: z.string({ error: "Veuillez confirmer le mot de passe" }),
birthdate: z.string().optional(),
gender: z.string().optional(),
marketingOptIn: z.boolean().optional(),
})
.refine((data) => data.password === data.confirmPassword, {
message: "Les deux mots de passe ne correspondent pas",
path: ["confirmPassword"],
});
const loginSchema = z.object({
email: z.string().email("Email invalide"),
email: emailSchema,
password: z.string().min(1, "Mot de passe requis"),
});

View 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 };

View 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 };

View 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);
});
});

View 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();
});
});

View File

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

View File

@@ -0,0 +1,134 @@
const request = require("supertest");
const app = require("../src/app");
const prisma = require("../src/config/prisma");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");
const mailer = require("../src/config/mailer");
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
const DAY = 86400000;
const PREFIX = "notif-";
const CODE = "NOTIFTICK1";
let ctx = {};
async function cleanup() {
const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } });
const ids = us.map((u) => u.id);
const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
await prisma.ticket.deleteMany({ where: { code: CODE } });
await prisma.user.deleteMany({ where: { id: { in: ids } } });
}
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
const jourJ = (d) =>
prisma.gameSettings.update({
where: { id: ctx.settingsId },
data: { startDate: new Date(Date.now() - d * DAY) },
});
// Un tirage reel peut exister en base : la table GrandPrizeWinner est unique
// pour tout le jeu. On la met de cote le temps du test, puis on la restaure.
async function mettreDeCoteLeGagnant() {
const existant = await prisma.grandPrizeWinner.findFirst();
if (existant) await prisma.grandPrizeWinner.delete({ where: { id: existant.id } });
return existant;
}
async function restaurerLeGagnant(sauvegarde) {
if (!sauvegarde) return;
await prisma.grandPrizeWinner.deleteMany({});
await prisma.grandPrizeWinner.create({
data: {
id: sauvegarde.id,
userId: sauvegarde.userId,
prize: sauvegarde.prize,
drawnAt: sauvegarde.drawnAt,
},
});
}
beforeAll(async () => {
ctx.gagnantSauvegarde = await mettreDeCoteLeGagnant();
await cleanup();
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
ctx.settingsId = row.id;
ctx.startDateOriginale = row.startDate;
const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
await prisma.ticket.create({ data: { code: CODE, lotId: lot.id } });
const h = await bcrypt.hash("Password123", 10);
ctx.admin = await prisma.user.create({ data: { firstname: "Alice", lastname: "Admin", email: PREFIX + "admin@test.local", password: h, role: "ADMIN", isEmailVerified: true } });
ctx.client = await prisma.user.create({ data: { firstname: "Chloe", lastname: "Martin", email: PREFIX + "chloe@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
ctx.tAdmin = tk(ctx.admin);
await jourJ(1);
mailer.sendMail.mockResolvedValue(true);
await request(app).post("/api/game/participate").set("Authorization", "Bearer " + tk(ctx.client)).send({ code: CODE }).expect(201);
await jourJ(35);
});
afterAll(async () => {
await restaurerLeGagnant(ctx.gagnantSauvegarde);
await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } });
await cleanup();
await prisma.$disconnect();
});
describe("Notification du grand gagnant", () => {
beforeEach(() => jest.clearAllMocks());
test("le gagnant recoit un email a son adresse", async () => {
mailer.sendMail.mockResolvedValue(true);
const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin).expect(201);
expect(res.body.emailSent).toBe(true);
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
// Le tirage est aleatoire parmi tous les participants : on verifie que
// l email part bien au gagnant annonce, quel qu il soit.
const gagnant = res.body.winner.user;
const envoi = mailer.sendMail.mock.calls[0][0];
expect(envoi.to).toBe(gagnant.email);
expect(envoi.subject).toMatch(/gros lot/i);
expect(envoi.html).toContain(gagnant.firstname);
expect(envoi.html).toContain("360");
await prisma.grandPrizeWinner.deleteMany({});
});
test("un SMTP en panne n invalide pas le tirage", async () => {
mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible"));
const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
expect(res.status).toBe(201);
expect(res.body.emailSent).toBe(false);
expect(res.body.message).toMatch(/contacter directement/);
// Le tirage reste enregistre : c est un acte irreversible.
const enBase = await prisma.grandPrizeWinner.findFirst();
expect(enBase).not.toBeNull();
expect(enBase.userId).toBe(res.body.winner.user.id);
await prisma.grandPrizeWinner.deleteMany({});
});
test("aucun email n est envoye si le tirage est refuse", async () => {
mailer.sendMail.mockResolvedValue(true);
await jourJ(1);
const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin);
expect(res.status).toBe(409);
expect(mailer.sendMail).not.toHaveBeenCalled();
await jourJ(35);
});
});

View File

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

View File

@@ -0,0 +1,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);
});
});

View 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/);
});
});

View 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);
});
});

View File

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

View File

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

View File

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

View File

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

View 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/);
});
});

View File

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

View 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();
});
});

View File

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

View File

@@ -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 = "";

View File

@@ -38,6 +38,16 @@ services:
JWT_SECRET: dev_secret_change_me
APP_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr
API_PUBLIC_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr
FRONTEND_URL: https://dev.dsp5-archi-o24a-4-5-g4.fr
# Injectés par le pipeline, jamais commités. Vides, les routes de
# connexion externe répondent 503 sans bloquer le démarrage.
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}"
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
FACEBOOK_APP_ID: "${FACEBOOK_APP_ID}"
FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET}"
# Vide, la vérification anti-robot Turnstile est désactivée.
TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY}"
command: >
sh -c "

View File

@@ -38,6 +38,16 @@ services:
JWT_SECRET: preprod_secret_change_me
APP_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr
API_PUBLIC_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr
FRONTEND_URL: https://preprod.dsp5-archi-o24a-4-5-g4.fr
# Injectés par le pipeline, jamais commités. Vides, les routes de
# connexion externe répondent 503 sans bloquer le démarrage.
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}"
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
FACEBOOK_APP_ID: "${FACEBOOK_APP_ID}"
FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET}"
# Vide, la vérification anti-robot Turnstile est désactivée.
TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY}"
command: >
sh -c "

View File

@@ -38,6 +38,16 @@ services:
JWT_SECRET: prod_secret_change_me
APP_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
API_PUBLIC_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
FRONTEND_URL: https://dsp5-archi-o24a-4-5-g4.fr,https://www.dsp5-archi-o24a-4-5-g4.fr
# Injectés par le pipeline, jamais commités. Vides, les routes de
# connexion externe répondent 503 sans bloquer le démarrage.
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}"
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
FACEBOOK_APP_ID: "${FACEBOOK_APP_ID}"
FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET}"
# Vide, la vérification anti-robot Turnstile est désactivée.
TURNSTILE_SECRET_KEY: "${TURNSTILE_SECRET_KEY}"
command: >
sh -c "

5
frontend/.gitignore vendored
View File

@@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
.env
# Editor directories and files
.vscode/*
@@ -22,3 +23,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Tests / coverage
coverage/
test-results/

View File

@@ -1,14 +1,21 @@
FROM node:22-alpine AS build
FROM node:22-slim AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
RUN npx playwright install --with-deps chromium
COPY . .
ARG VITE_API_URL
ARG VITE_GA_ID
ENV VITE_API_URL=${VITE_API_URL}
ENV VITE_GA_ID=${VITE_GA_ID}
ARG VITE_TURNSTILE_SITE_KEY
ENV VITE_TURNSTILE_SITE_KEY=${VITE_TURNSTILE_SITE_KEY}
RUN npm run build

View File

@@ -1,16 +1,275 @@
# React + Vite
# Thé Tip Top – Frontend
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
## Présentation
Currently, two official plugins are available:
Frontend React du jeu-concours fictif **Thé Tip Top**, réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**.
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
L’interface communique avec l’API backend et propose des parcours adaptés aux visiteurs, clients, employés et administrateurs.
## React Compiler
## Stack technique
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
| Technologie | Utilisation |
|---|---|
| React | Interface utilisateur |
| Vite | Développement et build |
| React Router | Navigation |
| Tailwind CSS | Interface responsive |
| Vitest | Tests |
| Testing Library | Tests fonctionnels |
| ESLint | Analyse statique |
| Nginx | Serveur web du conteneur |
| Docker | Conteneurisation |
## Expanding the ESLint configuration
## Fonctionnalités
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
### Visiteur
- accueil ;
- règles du jeu ;
- inscription ;
- connexion ;
- connexion Google ;
- pages légales et confidentialité ;
- CGU et accessibilité ;
- contact ;
- plan du site.
### Client
- profil ;
- saisie d’un code unique ;
- participation ;
- consultation des gains ;
- gestion de session.
### Employé
- recherche d’un gain ;
- vérification d’un ticket ;
- validation de la remise d’un lot.
### Administrateur
- statistiques ;
- gestion administrative ;
- création d’employés ;
- configuration du jeu ;
- tirage au sort ;
- consultation du grand gagnant.
## Authentification
Le frontend prend en charge l’authentification email/mot de passe, la vérification email, le JWT, les routes protégées et Google OAuth 2.0.
Flux OAuth :
```text
Frontend
↓
/api/auth/google
↓
Google
↓
Callback backend
↓
/auth/callback frontend
↓
JWT + utilisateur
↓
Redirection selon le rôle
```
## Rôles
- `CLIENT`
- `EMPLOYEE`
- `ADMIN`
Les protections frontend améliorent l’expérience utilisateur ; les autorisations sensibles restent également contrôlées côté backend.
## Configuration API
Le frontend utilise :
```env
VITE_API_URL=http://localhost:5000
```
Les services utilisent cette URL pour les routes `/api/auth`, `/api/game`, `/api/employee` et `/api/admin`.
Dans Jenkins, `VITE_API_URL` est injectée au moment du build Docker selon l’environnement cible.
## Installation locale
Prérequis : Node.js 22 et npm.
```bash
npm install
```
Puis, si nécessaire :
```env
VITE_API_URL=http://localhost:5000
```
## Développement
```bash
npm run dev
```
L’application Vite est généralement disponible sur `http://localhost:5173`.
## Qualité et tests
Lint :
```bash
npm run lint
```
Tests :
```bash
npm test -- --run
```
Build :
```bash
npm run build
```
Les tests couvrent notamment la connexion, l’inscription, la saisie de code, les routes protégées, le contexte d’authentification, les services et la bannière de cookies.
## Docker
Le frontend utilise un build multi-stage :
```text
Node.js
↓
npm ci
↓
Vite build
↓
dist/
↓
Nginx
```
## CI/CD
```text
feature/* → CI uniquement
develop → DEV automatique
release-* → PREPROD automatique
main → validation manuelle → PROD
```
Pour le frontend, Jenkins exécute notamment :
1. `npm ci` ;
2. `npm run lint` ;
3. `npm run build` ;
4. Docker build ;
5. publication dans le Registry ;
6. déploiement dans l’environnement cible.
## Environnements
| Environnement | Frontend | API |
|---|---|---|
| DEV | `https://dev.dsp5-archi-o24a-4-5-g4.fr` | `https://api-dev.dsp5-archi-o24a-4-5-g4.fr` |
| PREPROD | `https://preprod.dsp5-archi-o24a-4-5-g4.fr` | `https://api-preprod.dsp5-archi-o24a-4-5-g4.fr` |
| PROD | `https://dsp5-archi-o24a-4-5-g4.fr` | `https://api.dsp5-archi-o24a-4-5-g4.fr` |
## Architecture applicative
```text
Navigateur
↓ HTTPS
Traefik
↓
Nginx / React
↓
API Express
↓
PostgreSQL
```
Le frontend n’accède jamais directement à PostgreSQL.
## Pages principales
Le projet comprend notamment les pages suivantes :
- accueil ;
- inscription ;
- connexion ;
- callback OAuth ;
- définition du mot de passe ;
- saisie du code unique ;
- gains ;
- profil ;
- espace employé ;
- espace administrateur ;
- règles du jeu ;
- contact ;
- mentions légales ;
- confidentialité ;
- CGU ;
- accessibilité ;
- plan du site ;
- page 404.
## Sécurité
- routes protégées ;
- gestion centralisée de l’authentification ;
- traitement des erreurs 401 ;
- redirection selon le rôle ;
- HTTPS sur les environnements déployés ;
- aucun secret OAuth privé dans le frontend.
> Le `GOOGLE_CLIENT_SECRET`, le secret JWT, les identifiants SMTP et les mots de passe de base de données ne doivent jamais être exposés dans le frontend.
## Arborescence simplifiée
```text
frontend/
├── src/
│ ├── components/
│ ├── context/
│ ├── pages/
│ ├── services/
│ ├── test/
│ └── App.jsx
├── Dockerfile
├── nginx.conf
├── package.json
└── README.md
```
## Infrastructure globale
```text
Développeur
↓
Gitea
↓
Jenkins
↓
Tests + Lint + Build
↓
Docker Registry
↓
DEV / PREPROD / PROD
↓
Traefik + HTTPS
```
Supervision : Prometheus, Grafana, cAdvisor et Node Exporter.
## Projet
Projet étudiant fictif réalisé dans le cadre de la certification **Expert en Stratégie et Transformation Digitale**.
Client fictif : **Thé Tip Top**.

View File

@@ -8,6 +8,7 @@ export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
ignores: ['scripts/**'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
@@ -18,4 +19,13 @@ export default defineConfig([
parserOptions: { ecmaFeatures: { jsx: true } },
},
},
{
// Scripts Node exécutés à la compilation (ex. prerender.js), jamais
// dans le navigateur : globals Node, pas de règles React.
files: ['scripts/**/*.js'],
extends: [js.configs.recommended],
languageOptions: {
globals: globals.node,
},
},
])

View File

@@ -5,7 +5,43 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<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." />
<link rel="canonical" href="https://www.dsp5-archi-o24a-4-5-g4.fr/accueil" />
<title>Jeu concours - Thé Tip Top</title>
<!-- Open Graph : valeurs par défaut, mises à jour par page via le hook usePageMeta -->
<meta property="og:site_name" content="Thé Tip Top" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="fr_FR" />
<meta property="og:title" content="Jeu concours - Thé Tip Top" />
<meta property="og:description" content="Participez au jeu-concours Thé Tip Top : saisissez le code de votre ticket et découvrez immédiatement votre lot." />
<meta property="og:url" content="https://www.dsp5-archi-o24a-4-5-g4.fr/accueil" />
<meta property="og:image" content="https://www.dsp5-archi-o24a-4-5-g4.fr/og-image.webp" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Jeu concours - Thé Tip Top" />
<meta name="twitter:description" content="Participez au jeu-concours Thé Tip Top : saisissez le code de votre ticket et découvrez immédiatement votre lot." />
<meta name="twitter:image" content="https://www.dsp5-archi-o24a-4-5-g4.fr/og-image.webp" />
<!-- Schema.org : identité du site uniquement, aucune adresse (non communiquée) -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "Organization",
"name": "Thé Tip Top",
"url": "https://www.dsp5-archi-o24a-4-5-g4.fr/",
"email": "thetiptop.gp4@gmail.com"
},
{
"@type": "WebSite",
"name": "Thé Tip Top",
"url": "https://www.dsp5-archi-o24a-4-5-g4.fr/"
}
]
}
</script>
</head>
<body>
<div id="root"></div>

View File

@@ -6,6 +6,12 @@ server {
index index.html;
location / {
try_files $uri $uri/ /index.html;
# $uri/index.html (et non $uri/) : sert directement le fichier des
# pages pré-rendues (ex. /accueil -> dist/accueil/index.html) sans
# passer par la redirection 301 vers l'URL à slash final que nginx
# déclenche pour une résolution de type répertoire ($uri/). Les
# canonical/sitemap pointent vers les URLs sans slash : elles
# doivent répondre 200 directement, pas via un redirect.
try_files $uri $uri/index.html /index.html;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -5,13 +5,16 @@
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build": "vite build && node scripts/prerender.js",
"prerender": "node scripts/prerender.js",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"@marsidev/react-turnstile": "^1.6.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.17.0"
@@ -25,11 +28,13 @@
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.1.11",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"jsdom": "^29.1.1",
"playwright": "^1.62.1",
"tailwindcss": "^4.3.0",
"vite": "^8.0.12",
"vitest": "^4.1.10"

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

View File

@@ -3,10 +3,13 @@ User-agent: *
# Pages publiques autorisées
Allow: /
Allow: /accueil
Allow: /notre-concept
Allow: /regles-jeu
Allow: /contact
Allow: /mentions-legales
Allow: /confidentialite
Allow: /accessibilite
Allow: /cgu
Allow: /sitemap
Allow: /inscription
Allow: /connexion
@@ -20,4 +23,4 @@ Disallow: /saisir-code-unique
Disallow: /perdu
# Sitemap
Sitemap: https://thetiptop.fr/sitemap
Sitemap: https://www.dsp5-archi-o24a-4-5-g4.fr/sitemap.xml

View 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>

View 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)
})

View File

@@ -5,7 +5,12 @@ import ProtectedRoute from './components/ProtectedRoute.jsx'
import CookieBanner from './components/CookieBanner.jsx'
import Connexion from './pages/Connexion'
import Inscription from './pages/Inscription'
import AuthCallback from './pages/AuthCallback'
import DefinirMotDePasse from './pages/DefinirMotDePasse'
import MotDePasseOublie from './pages/MotDePasseOublie'
import NouveauMotDePasse from './pages/NouveauMotDePasse'
import Accueil from './pages/Accueil'
import NotreConcept from './pages/NotreConcept'
import ReglesJeu from './pages/ReglesJeu'
import MesGains from './pages/MesGains'
import Employe from './pages/Employe'
@@ -29,9 +34,14 @@ export default function App() {
<Route path="/" element={<Navigate to="/accueil" replace />} />
<Route path="/connexion" element={<Connexion />} />
<Route path="/inscription" element={<Inscription />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/mot-de-passe-oublie" element={<MotDePasseOublie />} />
<Route path="/definir-mot-de-passe/:token" element={<DefinirMotDePasse />} />
<Route path="/nouveau-mot-de-passe" element={<NouveauMotDePasse />} />
{/* Pages publiques */}
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />
<Route path="/notre-concept" element={<Layout><NotreConcept /></Layout>} />
<Route path="/regles-jeu" element={<Layout><ReglesJeu /></Layout>} />
<Route path="/mentions-legales" element={<Layout><MentionsLegales /></Layout>} />
<Route path="/confidentialite" element={<Layout><Confidentialite /></Layout>} />

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

View File

@@ -0,0 +1,124 @@
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
const renderWidget = () => {
if (
cancelled ||
!containerRef.current ||
!window.turnstile
) {
return
}
if (widgetIdRef.current !== null) {
try {
window.turnstile.remove(widgetIdRef.current)
} catch {
// Widget déjà supprimé
}
widgetIdRef.current = null
}
containerRef.current.innerHTML = ''
widgetIdRef.current = 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('')
},
}
)
}
const existingScript = document.getElementById(
TURNSTILE_SCRIPT_ID
)
if (window.turnstile) {
renderWidget()
return
}
if (existingScript) {
existingScript.addEventListener('load', renderWidget, {
once: true,
})
return () => {
cancelled = true
}
}
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
if (
widgetIdRef.current !== null &&
window.turnstile
) {
try {
window.turnstile.remove(widgetIdRef.current)
} catch {
// Widget déjà supprimé
}
}
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"
/>
)
}

View File

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

View File

@@ -20,7 +20,7 @@ export default function Footer() {
<footer className="bg-[#006B6B] text-white">
{/* 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 */}
<div>
@@ -57,6 +57,16 @@ export default function Footer() {
</FooterLink>
</li>
<li>
<FooterLink
to="/notre-concept"
pathname={pathname}
className="hover:text-white transition"
>
Notre concept
</FooterLink>
</li>
<li>
<FooterLink
to="/regles-jeu"
@@ -179,7 +189,6 @@ export default function Footer() {
>
01 84 80 20 34
</a>
<a
href="mailto:contact@thetiptop.fr"
className="inline-block mt-3 font-medium hover:text-teal-300"
@@ -198,9 +207,23 @@ export default function Footer() {
{/* Barre basse */}
<div className="border-t border-teal-800">
<div className="max-w-7xl mx-auto px-6 py-4 text-center">
<p className="text-teal-100 text-xs leading-relaxed">
Jeu avec obligation d'achat. Participation réservée aux personnes majeures résidant en
France métropolitaine, sur présentation d'un ticket de caisse supérieur à 49 € émis
dans la boutique Thé Tip Top de Nice. Le nombre de participations n'augmente pas les
chances de remporter le gros lot.{' '}
<a
href="/reglement-jeu-concours-100-gagnant.pdf"
className="underline hover:text-white"
>
Télécharger le règlement (PDF)
</a>
</p>
</div>
<div className="max-w-7xl mx-auto px-6 py-5 flex flex-col md:flex-row justify-between items-center gap-3">
<p className="text-teal-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.
<br className="md:hidden" />
<span className="md:before:content-['_—_']">

View File

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

View File

@@ -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>
)
}

View File

@@ -1,5 +1,6 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { homeFor, INITIAL_PASSWORD_PATH } from '../config/roles'
export default function ProtectedRoute({ children, roles }) {
const { user } = useAuth()
@@ -8,9 +9,14 @@ export default function ProtectedRoute({ children, roles }) {
return <Navigate to="/connexion" replace />
}
// Mot de passe provisoire : aucune page protégée tant qu'il n'est pas
// remplacé (le backend refuse de toute façon les appels à l'API).
if (user.mustChangePassword) {
return <Navigate to={INITIAL_PASSWORD_PATH} replace />
}
if (roles && !roles.includes(user.role)) {
const redirects = { ADMIN: '/admin', EMPLOYEE: '/employe', CLIENT: '/mes-gains' }
return <Navigate to={redirects[user.role] || '/connexion'} replace />
return <Navigate to={homeFor(user)} replace />
}
return children

View File

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

View File

@@ -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>
)
}

View 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)

View 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'
}

View 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])
}

View 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])
}

View File

@@ -1 +1,54 @@
@import "tailwindcss";
/* Animation d'entrée de la bannière d'accueil : l'illustration glisse
légèrement depuis la droite en grossissant jusqu'à sa taille finale, et
les cartes de gains apparaissent juste après avec un léger décalage
(delay défini au cas par cas via [animation-delay:...]). */
@keyframes banner-image-enter {
0% { opacity: 0; transform: translateX(70px) scale(0.9); }
100% { opacity: 1; transform: translateX(0) scale(1); }
}
@keyframes banner-badge-enter {
0% { opacity: 0; transform: translateY(16px) scale(0.9); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
/* Flottement continu des cartes de la bannière, une fois qu'elles sont
apparues (deux variantes légèrement différentes pour éviter un
mouvement trop synchronisé entre les deux cartes). Appliqué sur un
conteneur séparé de celui qui gère l'inclinaison au survol (tilt), pour
que les deux animations du `transform` ne se marchent pas dessus. */
@keyframes card-float-a {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-10px) rotate(1.5deg); }
}
@keyframes card-float-b {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-7px) rotate(-1.5deg); }
}
/* Dérive douce des particules de thé générées en JS (position, taille,
durée et délai tirés au sort côté composant, amplitude reçue ici via la
variable CSS --tea-drift posée en style inline par particule). */
@keyframes tea-drift {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(calc(-1 * var(--tea-drift, 12px))) rotate(8deg); }
}
.animate-banner-image { animation: banner-image-enter 900ms cubic-bezier(0.22, 1, 0.36, 1) both; }
.animate-banner-badge { animation: banner-badge-enter 600ms cubic-bezier(0.22, 1, 0.36, 1) both; }
.animate-card-float-a { animation: card-float-a 4.5s ease-in-out infinite; }
.animate-card-float-b { animation: card-float-b 5.5s ease-in-out infinite; }
.animate-tea-drift { animation: tea-drift 6s ease-in-out infinite; }
/* Respecte les préférences d'accessibilité : pas d'animation pour les
personnes qui ont désactivé les mouvements dans leur OS. */
@media (prefers-reduced-motion: reduce) {
.animate-banner-image,
.animate-banner-badge,
.animate-card-float-a,
.animate-card-float-b,
.animate-tea-drift {
animation: none;
}
}

View File

@@ -1,4 +1,11 @@
import usePageMeta from '../hooks/usePageMeta'
export default function Accessibilite() {
usePageMeta({
title: 'Déclaration d’accessibilité | Thé Tip Top',
description: "État de conformité RGAA du site Thé Tip Top et procédure pour signaler un problème d'accessibilité rencontré sur le site.",
})
return (
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
@@ -31,7 +38,7 @@ export default function Accessibilite() {
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
Si vous rencontrez un obstacle à l'accessibilité sur le site, vous pouvez nous en faire
part afin que nous puissions y remédier :<br />
<strong>Email :</strong> contact@thetiptop.fr
<strong>Email :</strong> thetiptop.gp4@gmail.com
</p>
</section>

View File

@@ -1,30 +1,221 @@
import { useMemo } from 'react'
import { Link } from 'react-router-dom'
import usePageMeta from '../hooks/usePageMeta'
import useGameDates from '../hooks/useGameDates'
import { formatDay, formatRange } from '../utils/gameDates'
import bannerImg from '../assets/img/banner-accueil.webp'
const gains = [
{ label: 'Infuseur à thé', top: '26%', right: '26%', delay: 700, variant: 'a' },
{ label: 'Boîte 100 g détox ou infusion', top: '22%', right: '1%', delay: 850, variant: 'b' },
{ label: 'Boîte 100 g thé signature', top: '48%', right: '30%', delay: 1000, variant: 'a' },
{ label: 'Coffret découverte 39 €', top: '78%', right: '2%', delay: 1150, variant: 'b' },
{ label: 'Coffret découverte 69 €', top: '84%', right: '24%', delay: 1300, variant: 'a' },
]
function handleCardTilt(e) {
const el = e.currentTarget
const rect = el.getBoundingClientRect()
const px = (e.clientX - rect.left) / rect.width - 0.5
const py = (e.clientY - rect.top) / rect.height - 0.5
el.style.transform = `perspective(700px) rotateX(${(-py * 12).toFixed(2)}deg) rotateY(${(px * 12).toFixed(2)}deg) scale(1.04)`
}
function resetCardTilt(e) {
e.currentTarget.style.transform = ''
}
const TEA_EMOJI = ['🍃', '🍵', '🫖', '☕', '🥄', '✨']
function createTeaParticles(count) {
return Array.from({ length: count }, (_, i) => ({
id: i,
emoji: TEA_EMOJI[i % TEA_EMOJI.length],
left: Math.round(Math.random() * 100),
top: Math.round(Math.random() * 100),
size: Math.round(20 + Math.random() * 26),
duration: +(4 + Math.random() * 4).toFixed(1),
delay: +(Math.random() * 4).toFixed(1),
opacity: +(0.18 + Math.random() * 0.22).toFixed(2),
drift: Math.round(12 + Math.random() * 20),
}))
}
// Les dates viennent du tableau de bord admin (voir useGameDates).
const buildFaqs = ({ start, lastPlayDay, lastClaimDay }) => [
{
question: 'Comment obtenir un code ?',
answer: `Achetez pour plus de 49 € dans notre boutique Thé Tip Top de Nice, du ${formatRange(start, lastPlayDay)} : un code unique à 10 caractères figure sur votre ticket de caisse ou votre facture.`,
},
{
question: 'Quels sont les lots à gagner ?',
answer: (
<>
Tous les tickets sont gagnants ! Selon votre ticket, vous pouvez remporter :
<ul className="list-disc list-inside space-y-1 mt-2">
<li>Un infuseur à thé</li>
<li>Une boîte de 100 g de thé détox ou d'infusion</li>
<li>Une boîte de 100 g de thé signature</li>
<li>Un coffret découverte d'une valeur de 39 €</li>
<li>Un coffret découverte d'une valeur de 69 €</li>
</ul>
Un tirage au sort désigne en plus un grand gagnant qui remporte une année de thé d'une valeur de 360 €.
</>
),
plainAnswer: "Tous les tickets sont gagnants ! Selon votre ticket, vous pouvez remporter : un infuseur à thé, une boîte de 100 g de thé détox ou d'infusion, une boîte de 100 g de thé signature, un coffret découverte d'une valeur de 39 €, ou un coffret découverte d'une valeur de 69 €. Un tirage au sort désigne en plus un grand gagnant qui remporte une année de thé d'une valeur de 360 €.",
},
{
question: 'Jusqu\'à quand puis-je jouer ?',
answer: `La saisie des codes est ouverte du ${formatRange(start, lastPlayDay)} inclus. Les lots sont ensuite à retirer jusqu'au ${formatDay(lastClaimDay)}.`,
},
{
question: 'Que faire si mon code n\'est pas reconnu ?',
answer: "Vérifiez que le code a été saisi sans espace ni erreur. Si le problème persiste, contactez-nous depuis la page Contact.",
},
{
question: 'Où retirer mon lot ?',
answer: `Dans notre boutique de Nice, sur présentation de votre compte utilisateur validé et de la confirmation de gain affichée dans votre espace « Mes gains », jusqu'au ${formatDay(lastClaimDay)}.`,
},
]
const buildFaqJsonLd = (faqs) => ({
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: faqs.map((faq) => ({
'@type': 'Question',
name: faq.question,
acceptedAnswer: {
'@type': 'Answer',
text: faq.plainAnswer || faq.answer,
},
})),
})
export default function Accueil() {
const gameDates = useGameDates()
const period = formatRange(gameDates.start, gameDates.lastPlayDay)
const faqs = useMemo(() => buildFaqs(gameDates), [gameDates])
const faqJsonLd = useMemo(() => buildFaqJsonLd(faqs), [faqs])
const bannerAlt = `Jeu-concours 100 % Gagnant Thé Tip Top, du ${period} : tentez de gagner un an de thé d'une valeur de 360 € pour l'ouverture de notre 10e boutique à Nice.`
usePageMeta({
title: `Jeu-concours 100 % Gagnant du ${formatRange(gameDates.start, gameDates.lastPlayDay, { withYear: false })} | Thé Tip Top`,
description: `Du ${formatRange(gameDates.start, gameDates.lastPlayDay, { withYear: false })}, tout ticket de plus de 49 € de notre boutique de Nice porte un code 100 % gagnant. Saisissez le vôtre et repartez avec votre lot.`,
})
const teaParticles = useMemo(() => createTeaParticles(30), [])
return (
<div className="bg-gray-50 min-h-screen">
{/* Hero */}
<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">
<h1 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-[#006B6B] mb-4 sm:mb-6 leading-tight">
Bienvenue au jeu-concours Thé Tip Top
</h1>
<p className="text-base sm:text-lg md:text-xl text-gray-600 mb-8 sm:mb-10">
À l'occasion de l'ouverture de notre 10ᵉ boutique à Nice, participez à notre jeu-concours
et tentez de gagner des lots exceptionnels ! Tous les tickets sont gagnants, alors n'attendez
plus pour découvrir votre lot.
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }} />
<section className="relative bg-[#006B6B] overflow-hidden">
<div className="max-w-7xl mx-auto px-4 sm:px-6 relative py-10 lg:py-0 lg:h-[680px] xl:h-[780px]">
<div
className="hidden lg:block absolute right-[-10%] top-1/2 -translate-y-1/2 w-[75%] h-[75%] rounded-full bg-white/10 blur-3xl"
aria-hidden="true"
/>
<div className="pointer-events-none absolute inset-0 overflow-hidden z-0" aria-hidden="true">
{teaParticles.map((p) => (
<span
key={p.id}
className="absolute animate-tea-drift select-none"
style={{
left: `${p.left}%`,
top: `${p.top}%`,
fontSize: `${p.size}px`,
opacity: p.opacity,
animationDuration: `${p.duration}s`,
animationDelay: `${p.delay}s`,
'--tea-drift': `${p.drift}px`,
}}
>
{p.emoji}
</span>
))}
</div>
<div className="static lg:absolute lg:left-0 lg:top-10 max-w-full lg:max-w-md mb-6 lg:mb-0">
<p className="inline-block text-sm sm:text-base font-bold uppercase tracking-widest text-amber-300 border border-white/70 rounded-lg px-3.5 py-2 mb-3">
Du {formatRange(gameDates.start, gameDates.lastPlayDay, { padDay: true })}
</p>
<h1 className="text-3xl sm:text-5xl lg:text-6xl font-extrabold text-white leading-[1.05]">
Jeu-concours{' '}
<span className="text-amber-300">100&nbsp;% 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="flex flex-col sm:flex-row justify-center gap-3 sm:gap-4">
<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&nbsp;<span className="text-[#006B6B]">€</span>
</p>
<p className="text-xs sm:text-sm font-semibold text-gray-800 whitespace-nowrap mt-1.5">de thé à gagner</p>
</div>
</div>
</div>
{gains.map((gain) => (
<div
key={gain.label}
className="shrink-0 lg:absolute animate-banner-badge"
style={{ top: gain.top, right: gain.right, animationDelay: `${gain.delay}ms` }}
>
<div className={gain.variant === 'a' ? 'animate-card-float-a' : 'animate-card-float-b'}>
<div
onMouseMove={handleCardTilt}
onMouseLeave={resetCardTilt}
className="bg-white rounded-2xl shadow-xl px-3.5 py-2.5 text-left transition-transform duration-200 ease-out will-change-transform cursor-default"
>
<span className="text-xs sm:text-sm font-semibold text-gray-800 whitespace-nowrap">{gain.label}</span>
</div>
</div>
</div>
))}
</div>
<div className="static lg:absolute lg:left-0 lg:bottom-24 xl:bottom-28 flex flex-col sm:flex-row gap-3 sm:gap-4">
<Link
to="/saisir-code-unique"
className="bg-[#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
</Link>
<Link
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
</Link>
@@ -32,22 +223,47 @@ export default function Accueil() {
</div>
</section>
{/* Comment participer */}
<section className="max-w-5xl mx-auto px-4 sm:px-6 pb-12 sm:pb-20">
<section className="max-w-5xl mx-auto px-4 pt-6 sm:px-6 pb-12 sm:pb-20">
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center">
Comment participer ?
</h2>
<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: '2', text: 'Recevez votre code unique' },
{ num: '3', text: 'Saisissez le code sur le site' },
{ num: '4', text: 'Découvrez votre lot' },
{ num: '1', text: "Achat de 49 € dans notre boutique de Nice, code sur le ticket" },
{ num: '2', text: 'Créez votre compte et saisissez le code' },
{ num: '3', text: 'Découvrez et retirez votre lot en boutique' },
].map((step) => (
<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>
<p className="text-sm sm:text-base text-gray-700">{step.text}</p>
</div>
</li>
))}
</ol>
</section>
<section className="max-w-3xl mx-auto px-4 sm:px-6 pb-16 sm:pb-24">
<h2 className="text-2xl sm:text-3xl font-bold text-[#006B6B] mb-6 sm:mb-8 text-center">
Questions fréquentes
</h2>
<div className="space-y-3">
{faqs.map((faq) => (
<details key={faq.question} className="group bg-white rounded-2xl shadow p-4 sm:p-6">
<summary className="flex items-center justify-between gap-3 cursor-pointer list-none font-semibold text-gray-800 text-sm sm:text-base">
{faq.question}
<svg
className="w-4 h-4 text-[#006B6B] flex-shrink-0 transition-transform group-open:rotate-180"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</summary>
<div className="text-gray-600 text-sm sm:text-base mt-3 leading-relaxed">
{faq.answer}
</div>
</details>
))}
</div>
</section>

View File

@@ -1,18 +1,52 @@
import { useState, useEffect } from 'react'
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi } from '../services/admin'
import { searchTicketApi, deliverClaimApi } from '../services/employee'
import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi, createEmployeeApi } from '../services/admin'
import EmployeeDeliveries from '../components/admin/EmployeeDeliveries'
import { useHandleAuthError } from '../hooks/useHandleAuthError'
import { formatDay, formatRange, gameDatesFromStart } from '../utils/gameDates'
function StatCard({ label, value, sub }) {
return (
<div className="bg-white rounded-xl shadow p-4 text-center">
<p className="text-2xl sm:text-3xl font-bold text-[#006B6B]">{value ?? '—'}</p>
<p className="text-xs sm:text-sm text-gray-500 mt-1">{label}</p>
{sub && <p className="text-xs text-gray-400 mt-0.5">{sub}</p>}
{sub && <p className="text-xs text-gray-500 mt-0.5">{sub}</p>}
</div>
)
}
// Les cinq états renvoyés par l'API, chacun avec sa couleur : « programmé »
// ne doit pas être confondu avec « retraits uniquement ».
const PHASE_BADGES = {
NOT_CONFIGURED: { label: 'Non lancé', className: 'bg-amber-100 text-amber-700' },
SCHEDULED: { label: 'Programmé', className: 'bg-indigo-100 text-indigo-700' },
OPEN: { label: 'Participations ouvertes', className: 'bg-green-100 text-green-700' },
CLAIM_ONLY: { label: 'Retraits uniquement', className: 'bg-blue-100 text-blue-700' },
CLOSED: { label: 'Terminé', className: 'bg-red-100 text-red-700' },
}
const PHASE_PANELS = {
NOT_CONFIGURED: 'bg-amber-50 border border-amber-200',
SCHEDULED: 'bg-indigo-50 border border-indigo-200',
OPEN: 'bg-white',
CLAIM_ONLY: 'bg-white',
CLOSED: 'bg-red-50 border border-red-200',
}
const PHASE_TITLES = {
NOT_CONFIGURED: 'Jeu-concours non lancé',
SCHEDULED: 'Jeu-concours programmé',
OPEN: 'Jeu-concours en cours',
CLAIM_ONLY: 'Participations closes, retraits en cours',
CLOSED: 'Jeu-concours terminé',
}
// Valeur attendue par <input type="datetime-local"> : heure locale, sans fuseau.
function toDateTimeLocalValue(dateStr) {
const date = new Date(dateStr)
const pad = (n) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: '2-digit', month: 'long', year: 'numeric',
@@ -31,24 +65,31 @@ export default function Admin() {
const [error, setError] = useState('')
const [showConfirm, setShowConfirm] = useState(false)
// 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)
// Lancement du jeu-concours
const [startDateInput, setStartDateInput] = useState('')
const [settingsSaving, setSettingsSaving] = useState(false)
const [settingsError, setSettingsError] = useState('')
const [settingsOk, setSettingsOk] = useState('')
// Contacts emailing
const [emailingContacts, setEmailingContacts] = useState(null)
const [emailingLoading, setEmailingLoading] = useState(false)
const [emailingError, setEmailingError] = useState('')
// Création d'un compte employé
const [employeeForm, setEmployeeForm] = useState({ firstname: '', lastname: '', email: '' })
const [employeeSaving, setEmployeeSaving] = useState(false)
const [employeeError, setEmployeeError] = useState('')
const [employeeOk, setEmployeeOk] = useState('')
// Incrémenté à chaque création pour recharger la liste des employés.
const [employeesVersion, setEmployeesVersion] = useState(0)
useEffect(() => {
Promise.all([getStatsApi(), getUsersApi(), getWinnerApi()])
.then(([s, u, w]) => {
setStats(s)
// Le champ part de la date enregistrée, celle affichée sur l'accueil.
if (s.gameWindow?.startDate) setStartDateInput(toDateTimeLocalValue(s.gameWindow.startDate))
setUsers(u.users)
setWinner(w)
})
@@ -56,7 +97,26 @@ export default function Admin() {
if (!handleAuthError(e)) setError(e.message)
})
.finally(() => setLoading(false))
}, [handleAuthError])
}, [handleAuthError])
const handleSaveGameSettings = async (e) => {
e.preventDefault()
setSettingsError('')
setSettingsOk('')
setSettingsSaving(true)
try {
// L'input datetime-local renvoie une heure locale sans fuseau : on la
// convertit en ISO pour que le serveur reçoive un instant sans ambiguïté.
const data = await setGameSettingsApi(new Date(startDateInput).toISOString())
setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev))
setSettingsOk(`${data.message} La page d'accueil affiche désormais ces dates.`)
setStartDateInput(toDateTimeLocalValue(data.gameWindow.startDate))
} catch (err) {
if (!handleAuthError(err)) setSettingsError(err.message)
} finally {
setSettingsSaving(false)
}
}
const handleDraw = async () => {
setShowConfirm(false)
@@ -103,49 +163,26 @@ export default function Admin() {
URL.revokeObjectURL(url)
}
const handleTicketSearch = async (e) => {
const handleCreateEmployee = async (e) => {
e.preventDefault()
setTicketResult(null)
setTicketSearchErr('')
setTicketDeliverErr('')
setTicketSuccess(false)
setTicketSearching(true)
setEmployeeError('')
setEmployeeOk('')
setEmployeeSaving(true)
try {
const data = await searchTicketApi(ticketCode.trim())
setTicketResult(data)
const data = await createEmployeeApi(employeeForm)
setEmployeeOk(`Compte créé pour ${data.employee.firstname} ${data.employee.lastname} : un email avec un mot de passe provisoire a été envoyé à ${data.employee.email}.`)
setEmployeeForm({ firstname: '', lastname: '', email: '' })
setEmployeesVersion((v) => v + 1)
} catch (err) {
if (!handleAuthError(err)) setTicketSearchErr(err.message)
if (!handleAuthError(err)) setEmployeeError(err.message)
} finally {
setTicketSearching(false)
}
}
const handleTicketDeliver = async () => {
if (!ticketResult?.claim?.id) return
setTicketDeliverErr('')
setTicketDelivering(true)
try {
await deliverClaimApi(ticketResult.claim.id)
setTicketResult((prev) => ({
...prev,
claim: { ...prev.claim, status: 'DELIVERED', claimedAt: new Date().toISOString() },
}))
setTicketSuccess(true)
setStats((prev) => prev ? {
...prev,
claimsPending: Math.max(0, (prev.claimsPending ?? 1) - 1),
claimsDelivered: (prev.claimsDelivered ?? 0) + 1,
} : prev)
} catch (err) {
if (!handleAuthError(err)) setTicketDeliverErr(err.message)
} finally {
setTicketDelivering(false)
setEmployeeSaving(false)
}
}
if (loading) return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<p className="text-gray-400">Chargement...</p>
<p className="text-gray-500">Chargement...</p>
</div>
)
@@ -157,6 +194,15 @@ export default function Admin() {
const winnerUser = winner?.winner?.user
// Aperçu en direct des dates publiques pour la date saisie.
const startInputDate = startDateInput ? new Date(startDateInput) : null
const startPreview = startInputDate && !Number.isNaN(startInputDate.getTime())
? gameDatesFromStart(startInputDate)
: null
const savedStart = stats?.gameWindow?.startDate
const startPreviewUnsaved = Boolean(startPreview) &&
(!savedStart || toDateTimeLocalValue(savedStart) !== startDateInput)
return (
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
<div className="max-w-5xl mx-auto space-y-6">
@@ -164,21 +210,75 @@ export default function Admin() {
<h1 className="text-xl sm:text-2xl font-bold text-gray-800">Tableau de bord Admin</h1>
{/* Fenêtre du jeu-concours */}
{stats?.gameWindow?.configured && (
<div className={`rounded-xl shadow p-4 sm:p-6 ${stats.gameWindow.isOpen ? 'bg-white' : 'bg-red-50 border border-red-200'}`}>
{stats?.gameWindow && (
<div className={`rounded-xl shadow p-4 sm:p-6 ${PHASE_PANELS[stats.gameWindow.phase] ?? 'bg-white'}`}>
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<p className="text-sm font-semibold text-gray-800">
Jeu-concours {stats.gameWindow.isOpen ? 'en cours' : 'terminé'}
</p>
<p className="text-xs text-gray-500 mt-1">
Début : {formatDate(stats.gameWindow.startDate)} — Fin des participations et réclamations : {formatDate(stats.gameWindow.claimDeadline)}
{PHASE_TITLES[stats.gameWindow.phase] ?? 'Jeu-concours'}
</p>
{stats.gameWindow.configured ? (
<p className="text-xs text-gray-500 mt-1">
Début : {formatDate(stats.gameWindow.startDate)}
<br />
Fin des participations : {formatDate(stats.gameWindow.participationDeadline)}
<br />
Fin des retraits de lots : {formatDate(stats.gameWindow.claimDeadline)}
</p>
) : (
<p className="text-xs text-gray-600 mt-1">
Choisissez une date de début pour ouvrir le jeu-concours.
</p>
)}
</div>
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${stats.gameWindow.isOpen ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
{stats.gameWindow.isOpen ? 'Ouvert' : 'Fermé'}
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${PHASE_BADGES[stats.gameWindow.phase]?.className ?? 'bg-gray-100 text-gray-700'}`}>
{PHASE_BADGES[stats.gameWindow.phase]?.label ?? 'État inconnu'}
</span>
</div>
<form
onSubmit={handleSaveGameSettings}
className="mt-4 pt-4 border-t border-gray-200 flex flex-col sm:flex-row sm:items-end gap-3"
>
<div className="w-full sm:flex-1 sm:min-w-[220px]">
<label htmlFor="game-start" className="block text-xs font-medium text-gray-700 mb-1">
{stats.gameWindow.configured ? 'Corriger la date de début' : 'Date de début du jeu-concours'}
</label>
<input
type="datetime-local"
id="game-start"
value={startDateInput}
onChange={(e) => setStartDateInput(e.target.value)}
required
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
<button
type="submit"
disabled={settingsSaving || !startDateInput}
className="w-full sm:w-auto bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm whitespace-nowrap"
>
{settingsSaving ? 'Enregistrement…' : stats.gameWindow.configured ? 'Mettre à jour' : 'Lancer le jeu'}
</button>
</form>
{startPreview && (
<p className="text-xs text-gray-700 mt-3" data-testid="homepage-dates-preview">
Affiché sur la page d'accueil :{' '}
<span className="font-semibold">
Du {formatRange(startPreview.start, startPreview.lastPlayDay, { padDay: true })}
</span>
, retrait des lots jusqu'au <span className="font-semibold">{formatDay(startPreview.lastClaimDay)}</span>
{startPreviewUnsaved && <span className="text-amber-700"> (non enregistré)</span>}
</p>
)}
<p className="text-xs text-gray-500 mt-2">
Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.
</p>
{settingsError && <p className="text-red-500 text-xs mt-2">{settingsError}</p>}
{settingsOk && <p className="text-green-600 text-xs mt-2">{settingsOk}</p>}
</div>
)}
@@ -302,7 +402,7 @@ export default function Admin() {
<tbody>
{emailingContacts.length === 0 && (
<tr>
<td colSpan={3} className="py-6 text-center text-gray-400 text-sm">
<td colSpan={3} className="py-6 text-center text-gray-500 text-sm">
Aucun contact n'a accepté l'emailing pour le moment
</td>
</tr>
@@ -324,95 +424,66 @@ export default function Admin() {
)}
</div>
{/* ── Remise de lot ── */}
{/* ── Création d'un compte employé ── */}
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Remise de lot</h2>
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-1">Créer un compte employé</h2>
<p className="text-xs sm:text-sm text-gray-500 mb-4">
Donne accès à l'espace employé (remise des lots en boutique). L'admin ne gère pas les remises lui-même.
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">
<label htmlFor="admin-ticket-code" className="sr-only">Code ticket</label>
<input
type="text"
id="admin-ticket-code"
value={ticketCode}
onChange={(e) => setTicketCode(e.target.value.toUpperCase())}
placeholder="Code ticket (10 caractères)"
maxLength={10}
className="flex-1 border border-gray-200 rounded-lg px-4 py-2.5 text-sm font-mono uppercase tracking-widest focus:outline-none focus:ring-2 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
<button
type="submit"
disabled={ticketSearching || ticketCode.trim().length === 0}
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2.5 rounded-lg transition-colors text-sm whitespace-nowrap"
>
{ticketSearching ? 'Recherche…' : 'Rechercher'}
</button>
</form>
{ticketSearchErr && (
<p className="text-red-500 text-sm mb-3">{ticketSearchErr}</p>
)}
{ticketResult && (
<div className="border border-gray-100 rounded-xl overflow-hidden">
{ticketSuccess && (
<div className="bg-green-500 px-4 py-2.5 flex items-center gap-2">
<svg className="w-4 h-4 text-white flex-shrink-0" fill="none" stroke="currentColor" strokeWidth={2.5} viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<p className="text-white text-sm font-medium">Lot remis avec succès.</p>
</div>
)}
<div className="px-4 py-3 flex items-start justify-between gap-3 border-b border-gray-100">
<div>
<p className="font-semibold text-gray-900 text-sm">{ticketResult.lot.name}</p>
<p className="text-gray-400 text-xs mt-0.5">{ticketResult.lot.description}</p>
{ticketResult.lot.value && <p className="text-[#006B6B] text-xs font-semibold mt-1">{ticketResult.lot.value} €</p>}
</div>
<span className={`flex-shrink-0 text-xs font-semibold px-2.5 py-1 rounded-full ${
ticketResult.claim?.status === 'DELIVERED' ? 'bg-green-100 text-green-700'
: ticketResult.claim?.status === 'CANCELLED' ? 'bg-gray-100 text-gray-500'
: 'bg-yellow-100 text-yellow-700'
}`}>
{ticketResult.claim?.status === 'DELIVERED' ? 'Remis'
: ticketResult.claim?.status === 'CANCELLED' ? 'Expiré'
: 'À remettre'}
</span>
</div>
<div className="px-4 py-3 flex items-center gap-3 border-b border-gray-100">
<div className="w-8 h-8 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-xs font-bold flex-shrink-0">
{(ticketResult.client.firstname?.[0] ?? '?').toUpperCase()}{(ticketResult.client.lastname?.[0] ?? '?').toUpperCase()}
</div>
<div>
<p className="text-sm font-medium text-gray-800">{ticketResult.client.firstname} {ticketResult.client.lastname}</p>
<p className="text-xs text-gray-500">{ticketResult.client.email}</p>
</div>
</div>
<div className="px-4 py-3 space-y-2">
{ticketDeliverErr && <p className="text-red-500 text-xs">{ticketDeliverErr}</p>}
{ticketResult.claim?.status === 'PENDING' && !ticketSuccess && (
<button
onClick={handleTicketDeliver}
disabled={ticketDelivering}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold py-2.5 rounded-lg transition-colors text-sm"
>
{ticketDelivering ? 'Confirmation…' : 'Confirmer la remise du lot'}
</button>
)}
<button
onClick={() => { setTicketCode(''); setTicketResult(null); setTicketSuccess(false); setTicketSearchErr(''); setTicketDeliverErr('') }}
className="w-full border border-gray-200 text-gray-500 hover:bg-gray-50 py-2 rounded-lg transition-colors text-sm"
>
Rechercher un autre code
</button>
</div>
<form onSubmit={handleCreateEmployee} className="grid sm:grid-cols-2 gap-3">
<div>
<label htmlFor="employee-firstname" className="block text-xs font-medium text-gray-700 mb-1">Prénom</label>
<input
type="text"
id="employee-firstname"
value={employeeForm.firstname}
onChange={(e) => setEmployeeForm({ ...employeeForm, firstname: e.target.value })}
required
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
)}
<div>
<label htmlFor="employee-lastname" className="block text-xs font-medium text-gray-700 mb-1">Nom</label>
<input
type="text"
id="employee-lastname"
value={employeeForm.lastname}
onChange={(e) => setEmployeeForm({ ...employeeForm, lastname: e.target.value })}
required
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
<div>
<label htmlFor="employee-email" className="block text-xs font-medium text-gray-700 mb-1">Email</label>
<input
type="email"
id="employee-email"
value={employeeForm.email}
onChange={(e) => setEmployeeForm({ ...employeeForm, email: e.target.value })}
required
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
<div className="sm:col-span-2 flex items-center gap-3 pt-1">
<button
type="submit"
disabled={employeeSaving}
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
>
{employeeSaving ? 'Création…' : 'Créer le compte'}
</button>
{employeeError && <p className="text-red-500 text-xs sm:text-sm">{employeeError}</p>}
{employeeOk && <p className="text-green-600 text-xs sm:text-sm">{employeeOk}</p>}
</div>
</form>
</div>
{/* ── Employés et remises des lots ── */}
<EmployeeDeliveries refreshKey={employeesVersion} onAuthError={handleAuthError} />
{/* ── Tirage au sort ── */}
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">Grand tirage au sort</h2>
@@ -436,10 +507,17 @@ export default function Admin() {
</div>
</div>
{winner?.winner?.createdAt && (
<p className="text-xs text-gray-400 text-right">
<p className="text-xs text-gray-500 text-right">
Tirage effectué le {formatDate(winner.winner.createdAt)}
</p>
)}
{/* Signalé uniquement au retour du tirage : le GET du gagnant ne
renvoie pas emailSent, et l'absence de la clé ne vaut pas échec. */}
{winner?.emailSent === false && (
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
L&apos;email de notification n&apos;a pas pu être envoyé au gagnant. Merci de le contacter directement à l&apos;adresse ci-dessus.
</p>
)}
</div>
) : (
/* Pas encore de tirage */
@@ -474,7 +552,7 @@ export default function Admin() {
{/* Liste participants */}
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">
Participants <span className="text-gray-400 font-normal text-sm">({users.length})</span>
Participants <span className="text-gray-500 font-normal text-sm">({users.length})</span>
</h2>
<div className="overflow-x-auto -mx-4 sm:mx-0">
<table className="w-full text-sm min-w-[400px]">
@@ -488,7 +566,7 @@ export default function Admin() {
<tbody>
{users.length === 0 && (
<tr>
<td colSpan={3} className="py-6 text-center text-gray-400 text-sm">
<td colSpan={3} className="py-6 text-center text-gray-500 text-sm">
Aucun participant pour le moment
</td>
</tr>

View File

@@ -0,0 +1,71 @@
import { useEffect, useRef } from 'react'
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { homeFor } from '../config/roles'
// Point d'arrivée après un aller-retour OAuth : le backend nous renvoie ici
// avec le JWT et le profil encodés dans l'URL.
export default function AuthCallback() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const { login } = useAuth()
const handled = useRef(false)
const errorParam = searchParams.get('error')
const token = searchParams.get('token')
const encodedUser = searchParams.get('user')
let error = ''
let user = null
if (errorParam) {
error = errorParam
} else if (!token || !encodedUser) {
error = 'Réponse de connexion incomplète.'
} else {
try {
// atob renvoie des octets : on les relit en UTF-8.
const bytes = Uint8Array.from(
atob(encodedUser),
(c) => c.charCodeAt(0)
)
user = JSON.parse(new TextDecoder().decode(bytes))
} catch {
error = 'Réponse de connexion illisible.'
}
}
useEffect(() => {
if (handled.current || !user) return
handled.current = true
login(user, token)
navigate(homeFor(user), { replace: true })
}, [user, token, login, navigate])
if (error) {
return (
<div className="min-h-screen bg-white flex flex-col justify-center items-center px-8 max-w-sm mx-auto text-center">
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
{error}
</div>
<Link
to="/connexion"
className="text-sm text-[#006B6B] font-medium hover:underline"
>
Retour à la connexion
</Link>
</div>
)
}
return (
<div className="min-h-screen bg-white flex flex-col justify-center items-center px-8 max-w-sm mx-auto text-center">
<p className="text-gray-500 text-sm">
Connexion en cours…
</p>
</div>
)
}

View File

@@ -1,4 +1,12 @@
import { Link } from 'react-router-dom'
import usePageMeta from '../hooks/usePageMeta'
export default function Cgu() {
usePageMeta({
title: 'Conditions générales d’utilisation | Thé Tip Top',
description: "CGU du site Thé Tip Top : conditions d'accès au jeu-concours, inscription, participation et gestion de votre compte.",
})
return (
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
@@ -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 modalités et conditions dans lesquelles la société Thé Tip Top met à
disposition des utilisateurs le site internet du jeu-concours « 100% Gagnant »,
accessible à l'adresse https://www.thetiptop-concours.fr.
accessible à l'adresse https://www.dsp5-archi-o24a-4-5-g4.fr.
</p>
<p>
Toute utilisation du Site implique l'acceptation pleine et entière des présentes
CGU. En cas de non-acceptation, l'utilisateur est invité à ne pas accéder au Site
ni à utiliser ses services.
</p>
<p>
Le règlement complet du jeu-concours, déposé auprès de Maître Arnaud Rick,
Commissaire de Justice, est{' '}
<a
href="/reglement-jeu-concours-100-gagnant.pdf"
className="text-[#006B6B] hover:underline font-medium"
>
téléchargeable au format PDF
</a>.
</p>
</div>
),
},
@@ -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
d'un justificatif d'identité pour vérifier l'âge déclaré.
</p>
<p><strong>Conditions géographiques —</strong> Le jeu-concours « 100% Gagnant » est
ouvert exclusivement aux résidents de France métropolitaine et des Départements et
Régions d'Outre-Mer (DROM). Sont exclus les ressortissants ou résidents de toute
autre zone géographique.
<p><strong>Conditions géographiques —</strong> La participation au jeu-concours
« 100% Gagnant » est réservée aux personnes majeures résidant en France
métropolitaine. Seuls les tickets de caisse ou factures d'un montant supérieur à
49 € émis dans la boutique Thé Tip Top de Nice, à l'occasion de son ouverture,
donnent droit à un code de participation.
</p>
<p><strong>Personnes exclues —</strong> Sont exclus de toute participation les membres
du personnel de la société Thé Tip Top et de l'agence Furious Ducks, leurs
@@ -77,16 +96,16 @@ export default function Cgu() {
<h3 className="font-semibold text-gray-800 mb-1">3.1 – Services proposés</h3>
<p>
Le site du jeu-concours « 100% Gagnant » est un site internet dédié permettant
aux clients de la société Thé Tip Top de participer à un jeu-concours à
l'occasion de l'ouverture de la 10ᵉ boutique de l'enseigne à Nice. Les services
proposés incluent :
aux clients de la société Thé Tip Top de participer à un jeu-concours réservé à
la boutique de Nice, à l'occasion de son ouverture. Les services proposés
incluent :
</p>
<ul className="list-disc list-inside space-y-2 mt-2">
<li>La présentation du jeu-concours, de ses conditions et des lots à gagner</li>
<li>La création d'un espace personnel pour chaque participant</li>
<li>La saisie et la validation des codes à 10 caractères figurant sur les tickets de caisse ou factures d'un montant supérieur à 49 €</li>
<li>La saisie et la validation des codes à 10 caractères figurant sur les tickets de caisse ou factures d'un montant supérieur à 49 € émis dans la boutique de Nice</li>
<li>La consultation de l'historique des participations et gains obtenus</li>
<li>La réclamation des lots gagnés, en ligne ou en boutique</li>
<li>La réclamation des lots gagnés dans la boutique de Nice</li>
</ul>
</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
correspondant selon la répartition préétablie :
</p>
<ul className="list-disc list-inside space-y-1 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>
<li>10 % des tickets : boîte de 100 g de thé signature</li>
<li>6 % des tickets : coffret découverte d'une valeur de 39 €</li>
<li>4 % des tickets : coffret découverte d'une valeur de 69 €</li>
<ul className="space-y-2 mt-2">
{[
{ pct: '60 %', text: 'infuseur à thé' },
{ pct: '20 %', text: "boîte de 100 g de thé détox ou d'infusion" },
{ pct: '10 %', text: 'boîte de 100 g de thé signature' },
{ pct: '6 %', text: "coffret découverte d'une valeur de 39 €" },
{ pct: '4 %', text: "coffret découverte d'une valeur de 69 €" },
].map((lot) => (
<li key={lot.pct} className="flex gap-2">
<span className="font-semibold text-[#006B6B] flex-shrink-0">{lot.pct}</span>
<span className="min-w-0 flex-1">des tickets : {lot.text}</span>
</li>
))}
</ul>
<p className="mt-2">
Un même utilisateur peut soumettre plusieurs codes distincts. La participation
@@ -116,8 +142,9 @@ export default function Cgu() {
Passé ce délai, les lots non réclamés sont définitivement perdus.
</p>
<p className="mt-2"><strong>Remise des lots —</strong> Les lots peuvent être réclamés
en boutique, sur présentation du compte utilisateur validé et de la confirmation
de gain.
exclusivement dans la boutique Thé Tip Top de Nice, sur présentation du compte
utilisateur validé et de la confirmation de gain. Aucun site marchand n'étant
ouvert, aucune remise ni expédition à distance n'est possible.
</p>
<p className="mt-2">
À l'issue du jeu-concours, un tirage au sort est effectué parmi l'ensemble des
@@ -207,11 +234,16 @@ export default function Cgu() {
<div>
<h3 className="font-semibold text-gray-800 mb-1">6.2 – Suppression du compte</h3>
<p>
L'utilisateur peut demander la suppression de son compte à tout moment en
contactant directement l'équipe via contact@thetiptop.fr. 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.
La suppression du compte n'est pas disponible en libre-service. L'utilisateur peut
la demander à tout moment en contactant directement l'équipe via
thetiptop.gp4@gmail.com, notamment depuis la rubrique dédiée de son{' '}
<Link to="/profile#suppression-compte" className="text-[#006B6B] hover:underline font-medium">
espace personnel
</Link>
. La suppression entraîne la perte définitive des participations et gains non
encore réclamés. Les données personnelles sont supprimées conformément aux
dispositions de la Politique de Confidentialité, sous réserve des obligations
légales de conservation.
</p>
</div>
<div>

View File

@@ -1,4 +1,11 @@
import usePageMeta from '../hooks/usePageMeta'
export default function Confidentialite() {
usePageMeta({
title: 'Politique de confidentialité | Thé Tip Top',
description: "Comment Thé Tip Top collecte et protège vos données personnelles dans le cadre du jeu-concours, et comment exercer vos droits RGPD.",
})
return (
<main className="bg-[#F0F0F0] min-h-screen px-4 sm:px-6 py-10 sm:py-16">
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
@@ -19,7 +26,7 @@ export default function Confidentialite() {
</h2>
<p className="text-gray-700 leading-relaxed text-sm sm:text-base">
Le responsable du traitement des données collectées via le site du jeu-concours
« 100% Gagnant » est la société Thé Tip Top, située au 18 rue Léon Frot, 75011 Paris.
« 100% Gagnant » est la société Thé Tip Top.
</p>
</section>
@@ -89,7 +96,7 @@ export default function Confidentialite() {
</h2>
<p className="text-gray-700 text-sm sm:text-base">
Pour toute question concernant vos données personnelles :<br />
<strong>Email :</strong> contact@thetiptop.fr
<strong>Email :</strong> thetiptop.gp4@gmail.com
</p>
</section>

View File

@@ -1,9 +1,18 @@
import { useState } from 'react'
import { useState, useRef } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import { loginApi } from '../services/auth'
import { useAuth } from '../context/AuthContext'
import usePageMeta from '../hooks/usePageMeta'
import Captcha from '../components/Captcha'
import { CAPTCHA_ENABLED } from '../config/captcha'
import { homeFor } from '../config/roles'
export default function Connexion() {
usePageMeta({
title: 'Connexion | Thé Tip Top',
description: "Connectez-vous à votre espace participant Thé Tip Top pour saisir votre code et suivre vos gains au jeu-concours 100 % Gagnant.",
})
const navigate = useNavigate()
const location = useLocation()
const { login } = useAuth()
@@ -12,21 +21,34 @@ export default function Connexion() {
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [captchaToken, setCaptchaToken] = useState('')
const captchaRef = useRef()
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
// Navigation pleine page (et non fetch) : le flux OAuth passe par des
// Navigation pleine page (et non fetch) : le flux OAuth passe par des
// redirections vers Google/Facebook puis retour sur /auth/callback.
const handleOAuthLogin = (provider) => {
const apiUrl = import.meta.env.VITE_API_URL || ''
window.location.href = `${apiUrl}/api/auth/${provider}`
}
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
setLoading(true)
try {
const { token, user } = await loginApi(form)
const { token, user } = await loginApi({ ...form, captchaToken })
login(user, token)
if (user.role === 'ADMIN') navigate('/admin')
else if (user.role === 'EMPLOYEE') navigate('/employe')
else navigate('/mes-gains')
// Un employé qui se connecte avec son mot de passe provisoire est
// envoyé directement sur le choix de son nouveau mot de passe.
navigate(homeFor(user))
} catch (err) {
setError(err.message)
// Le jeton a été consommé par le backend : on en redemande un.
captchaRef.current?.reset()
setCaptchaToken('')
} finally {
setLoading(false)
}
@@ -66,7 +88,7 @@ export default function Connexion() {
onChange={handleChange}
required
placeholder="Votre email"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
@@ -83,12 +105,12 @@ export default function Connexion() {
onChange={handleChange}
required
placeholder="Votre mot de passe"
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600"
aria-label="Afficher/masquer le mot de passe"
>
<div className="w-6 h-6 rounded-full border-2 border-gray-400 flex items-center justify-center">
@@ -99,15 +121,21 @@ export default function Connexion() {
</button>
</div>
<div className="text-right mt-1">
<button type="button" className="text-xs text-gray-400 hover:text-[#006B6B] hover:underline">
<Link
to="/mot-de-passe-oublie"
state={{ email: form.email }}
className="text-xs text-gray-500 hover:text-[#006B6B] hover:underline"
>
Mot de passe oublié ?
</button>
</Link>
</div>
</div>
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
<button
type="submit"
disabled={loading}
disabled={loading || (CAPTCHA_ENABLED && !captchaToken)}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
>
{loading ? 'Connexion...' : 'Se connecter'}
@@ -123,20 +151,26 @@ export default function Connexion() {
<div className="grid grid-cols-2 gap-3">
<button
type="button"
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"
>
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">
G
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path>
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path>
</svg>
Google
</button>
<button
type="button"
onClick={() => handleOAuthLogin('facebook')}
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">
f
</div>
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="12" fill="#1877F2" />
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />
</svg>
Facebook
</button>
</div>

View File

@@ -1,10 +1,18 @@
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() {
usePageMeta({
title: 'Contact | Thé Tip Top',
description:
"Une question sur le jeu-concours, votre participation ou votre lot ? Contactez l'équipe Thé Tip Top par email, téléphone ou via le formulaire en ligne.",
})
return (
<main className="bg-[#F0F0F0] px-4 sm:px-6 py-10 sm:py-16">
<div className="max-w-6xl mx-auto">
{/* Informations + image */}
<div className="grid md:grid-cols-2 gap-6 sm:gap-8 mb-8">
{/* Informations */}
@@ -15,16 +23,17 @@ export default function Contact() {
<p className="text-gray-600 leading-relaxed mb-6 sm:mb-8 text-sm sm:text-base">
Une question concernant le jeu-concours, votre participation ou votre lot ?
Notre équipe ThéTipTop est à votre écoute.
Notre équipe Thé Tip Top est à votre écoute.
</p>
<div className="space-y-5 text-gray-700">
<div>
<h2 className="font-semibold text-[#006B6B] mb-1">
Adresse
</h2>
<p className="text-sm sm:text-base">
ThéTipTop
Thé Tip Top
<br />
18 rue Léon Frot
<br />
@@ -55,26 +64,29 @@ export default function Contact() {
01 84 80 20 34
</a>
</div>
</div>
</section>
{/* Image */}
<section className="rounded-2xl shadow overflow-hidden min-h-80">
<section className="rounded-2xl shadow overflow-hidden h-64 sm:h-80 md:h-full md:min-h-0">
<img
src={imgContact}
alt="Une personne savourant un thé"
className="w-full h-full object-cover"
/>
</section>
</div>
{/* Formulaire Emmanuel intégré au design Alexis */}
{/* 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"
@@ -145,6 +157,7 @@ export default function Contact() {
>
Envoyer le message
</button>
</form>
</section>

View File

@@ -0,0 +1,144 @@
import { useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { resetPasswordApi } from '../services/auth'
import PasswordRules from '../components/PasswordRules'
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
// Sert les deux parcours qui passent par un jeton envoyé par email :
// la définition initiale du mot de passe après une inscription Google,
// et la réinitialisation classique depuis « mot de passe oublié ».
export default function DefinirMotDePasse() {
const { token } = useParams()
const navigate = useNavigate()
const [form, setForm] = useState({ password: '', confirmPassword: '' })
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const [loading, setLoading] = useState(false)
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
const handleSubmit = async (e) => {
e.preventDefault()
setError('')
if (!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 resetPasswordApi({ token, ...form })
setSuccess('Mot de passe enregistré. Vous pouvez maintenant vous connecter avec votre email.')
setTimeout(() => navigate('/connexion'), 2500)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
if (success) {
return (
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto text-center">
<div className="mb-4 px-4 py-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm">
{success}
</div>
<Link to="/connexion" className="text-sm text-[#006B6B] font-medium hover:underline">
Aller à la connexion
</Link>
</div>
)
}
return (
<div className="min-h-screen bg-white flex flex-col justify-center px-8 py-12 max-w-sm mx-auto">
<div className="mb-8">
<h1 className="text-4xl font-bold italic font-serif text-[#006B6B]">Nouveau mot de passe</h1>
{/* Texte neutre : la page sert au mot de passe oublié comme au
premier mot de passe d'un compte Google. */}
<p className="text-gray-500 mt-1 text-sm">
Choisissez le mot de passe que vous utiliserez pour vous connecter avec votre adresse email.
</p>
</div>
{error && (
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm" role="alert">
{error}
{/invalide ou expiré/i.test(error) && (
<Link to="/mot-de-passe-oublie" className="block mt-2 font-medium text-[#006B6B] hover:underline">
Demander un nouveau lien
</Link>
)}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-800 mb-1">
Nouveau mot de passe <span className="text-gray-800">*</span>
</label>
<input
type={showPassword ? 'text' : 'password'}
id="password"
name="password"
value={form.password}
onChange={handleChange}
required
autoComplete="new-password"
aria-describedby="password-rules"
placeholder="Votre nouveau mot de passe"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
<PasswordRules id="password-rules" password={form.password} />
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-800 mb-1">
Confirmer le mot de passe <span className="text-gray-800">*</span>
</label>
<input
type={showPassword ? 'text' : 'password'}
id="confirmPassword"
name="confirmPassword"
value={form.confirmPassword}
onChange={handleChange}
required
placeholder="Saisissez-le à nouveau"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]"
/>
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={showPassword}
onChange={(e) => setShowPassword(e.target.checked)}
className="w-5 h-5 accent-[#006B6B]"
/>
<span className="text-xs text-gray-500">Afficher les mots de passe</span>
</label>
<button
type="submit"
disabled={loading}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors"
>
{loading ? 'Enregistrement…' : 'Enregistrer mon mot de passe'}
</button>
</form>
<p className="text-center text-sm text-gray-500 mt-8">
<Link to="/connexion" className="text-gray-700 font-medium hover:underline">
Retour à la connexion
</Link>
</p>
</div>
)
}

View File

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

View File

@@ -2,19 +2,61 @@ import { useState, useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { registerApi, verifyCodeApi } from '../services/auth'
import { useAuth } from '../context/AuthContext'
import usePageMeta from '../hooks/usePageMeta'
import Captcha from '../components/Captcha'
import { CAPTCHA_ENABLED } from '../config/captcha'
import { isPasswordValid, PASSWORD_RULES_ERROR } from '../utils/passwordRules'
import PasswordRules from '../components/PasswordRules'
// Une naissance ne peut pas être dans le futur : borne haute du sélecteur.
const AUJOURD_HUI = new Date().toISOString().split('T')[0]
// 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
// "Homme" et "Femme" ; toute autre valeur retombe dans la catégorie "autre".
const GENRES = ['Femme', 'Homme', 'Autre']
export default function Inscription() {
usePageMeta({
title: 'Créer un compte | Thé Tip Top',
description: "Inscrivez-vous en quelques instants pour participer au jeu-concours 100 % Gagnant Thé Tip Top et saisir le code de votre ticket de caisse.",
})
const navigate = useNavigate()
const { login } = useAuth()
// Étape : 'form' | 'verify'
const [step, setStep] = useState('form')
const [email, setEmail] = useState('')
const [form, setForm] = useState({ email: '', lastName: '', firstName: '', password: '' })
const [form, setForm] = useState({
email: '',
lastName: '',
firstName: '',
password: '',
confirmPassword: '',
birthdate: '',
gender: '',
})
const [acceptedTerms, setAcceptedTerms] = useState(false)
// Consentement marketing : distinct des CGU et décoché par défaut, il doit
// rester un choix libre et non une condition d'inscription.
const [marketingOptIn, setMarketingOptIn] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const [captchaToken, setCaptchaToken] = useState('')
const captchaRef = useRef()
// Code à 4 chiffres
const [digits, setDigits] = useState(['', '', '', ''])
@@ -24,10 +66,35 @@ export default function Inscription() {
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
// Signalé dès que la confirmation est saisie, sans attendre l'envoi.
const passwordMismatch = form.confirmPassword !== '' && form.password !== form.confirmPassword
// Même route que la connexion : le provider crée le compte à la première venue.
const handleOAuthSignup = (provider) => {
const apiUrl = import.meta.env.VITE_API_URL || ''
window.location.href = `${apiUrl}/api/auth/${provider}`
}
// Étape 1 — Inscription
const handleSubmit = async (e) => {
e.preventDefault()
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)
try {
await registerApi({
@@ -35,11 +102,19 @@ export default function Inscription() {
firstname: form.firstName,
lastname: form.lastName,
password: form.password,
confirmPassword: form.confirmPassword,
birthdate: form.birthdate,
gender: form.gender,
marketingOptIn,
captchaToken,
})
setEmail(form.email)
setStep('verify')
} catch (err) {
setError(err.message)
// Le jeton a été consommé par le backend : on en redemande un.
captchaRef.current?.reset()
setCaptchaToken('')
} finally {
setLoading(false)
}
@@ -129,11 +204,11 @@ export default function Inscription() {
</button>
</form>
<p className="text-center text-xs text-gray-400 mt-6">
<p className="text-center text-xs text-gray-500 mt-6">
Le code expire dans 15 minutes.{' '}
<button
type="button"
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError('') }}
onClick={() => { setStep('form'); setDigits(['', '', '', '']); setCodeError(''); setCaptchaToken('') }}
className="text-[#006B6B] hover:underline"
>
Recommencer
@@ -161,38 +236,68 @@ export default function Inscription() {
<div>
<label htmlFor="reg-email" className="block text-sm font-medium text-gray-800 mb-1">Adresse email <span className="text-gray-800">*</span></label>
<input type="email" id="reg-email" name="email" value={form.email} onChange={handleChange} required placeholder="Votre email"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
</div>
<div>
<label htmlFor="reg-lastName" className="block text-sm font-medium text-gray-800 mb-1">Nom <span className="text-gray-800">*</span></label>
<input type="text" id="reg-lastName" name="lastName" value={form.lastName} onChange={handleChange} required placeholder="Votre nom"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
</div>
<div>
<label htmlFor="reg-firstName" className="block text-sm font-medium text-gray-800 mb-1">Prénom <span className="text-gray-800">*</span></label>
<input type="text" id="reg-firstName" name="firstName" value={form.firstName} onChange={handleChange} required placeholder="Votre prénom"
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
</div>
<div>
<label htmlFor="reg-birthdate" className="block text-sm font-medium text-gray-800 mb-1">Date de naissance <span className="text-gray-800">*</span></label>
<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]" />
<p className="text-xs text-gray-500 mt-1">Le jeu-concours est réservé aux personnes majeures.</p>
</div>
<div>
<label htmlFor="reg-gender" className="block text-sm font-medium text-gray-800 mb-1">Genre</label>
<select id="reg-gender" name="gender" value={form.gender} onChange={handleChange}
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]">
<option value="">Non précisé</option>
{GENRES.map((genre) => (
<option key={genre} value={genre}>{genre}</option>
))}
</select>
</div>
<div>
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
<div className="relative">
<input type={showPassword ? 'text' : 'password'} id="reg-password" name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
autoComplete="new-password" aria-describedby="reg-password-rules"
className="w-full border border-gray-200 rounded-lg px-4 py-3 pr-12 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
<button type="button" onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600" aria-label="Afficher/masquer">
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-600" aria-label="Afficher/masquer">
<div className="w-6 h-6 rounded-full border-2 border-gray-400 flex items-center justify-center">
{showPassword && <div className="w-1.5 h-1.5 rounded-full bg-gray-400" />}
</div>
</button>
</div>
<PasswordRules id="reg-password-rules" password={form.password} />
</div>
<div>
<label htmlFor="reg-confirm-password" className="block text-sm font-medium text-gray-800 mb-1">Confirmer le mot de passe <span className="text-gray-800">*</span></label>
<input type={showPassword ? 'text' : 'password'} id="reg-confirm-password" name="confirmPassword" value={form.confirmPassword} onChange={handleChange} required placeholder="Saisissez-le à nouveau"
autoComplete="new-password" aria-invalid={passwordMismatch} aria-describedby={passwordMismatch ? 'reg-confirm-password-error' : undefined}
className={`w-full border rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-500 focus:outline-none focus:ring-1 ${passwordMismatch ? 'border-red-300 focus:ring-red-400 focus:border-red-400' : 'border-gray-200 focus:ring-[#006B6B] focus:border-[#006B6B]'}`} />
{passwordMismatch && (
<p id="reg-confirm-password-error" className="text-xs text-red-600 mt-1">Les deux mots de passe ne correspondent pas.</p>
)}
</div>
<label className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" checked={acceptedTerms} onChange={(e) => setAcceptedTerms(e.target.checked)} required
className="mt-0.5 w-4 h-4 accent-[#006B6B] flex-shrink-0" />
className="mt-0.5 w-5 h-5 accent-[#006B6B] flex-shrink-0" />
<span className="text-xs text-gray-500 leading-relaxed">
J'accepte les{' '}
<Link to="/cgu" className="text-[#006B6B] hover:underline font-medium">conditions d'utilisation</Link>{' '}
@@ -201,7 +306,21 @@ export default function Inscription() {
</span>
</label>
<button type="submit" disabled={loading || !acceptedTerms}
<label className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" name="marketingOptIn" checked={marketingOptIn}
onChange={(e) => setMarketingOptIn(e.target.checked)}
className="mt-0.5 w-5 h-5 accent-[#006B6B] flex-shrink-0" />
<span className="text-xs text-gray-500 leading-relaxed">
J'accepte de recevoir les offres et actualités de Thé Tip Top par email.
<span className="block text-gray-500 mt-0.5">
Facultatif — vous pouvez vous désinscrire à tout moment.
</span>
</span>
</label>
<Captcha ref={captchaRef} onToken={setCaptchaToken} />
<button type="submit" disabled={loading || !acceptedTerms || (CAPTCHA_ENABLED && !captchaToken)}
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors">
{loading ? 'Inscription…' : "S'inscrire"}
</button>
@@ -214,12 +333,20 @@ export default function Inscription() {
</div>
<div className="grid grid-cols-2 gap-3">
<button type="button" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">G</div>
<button type="button" onClick={() => handleOAuthSignup('google')} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path>
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path>
</svg>
Google
</button>
<button type="button" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">f</div>
<button type="button" onClick={() => handleOAuthSignup('facebook')} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="12" fill="#1877F2" />
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />
</svg>
Facebook
</button>
</div>

View File

@@ -1,4 +1,11 @@
import usePageMeta from '../hooks/usePageMeta'
export default function MentionsLegales() {
usePageMeta({
title: 'Mentions légales | Thé Tip Top',
description: "Mentions légales du site Thé Tip Top : éditeur, hébergeur, propriété intellectuelle et informations relatives au jeu-concours 100 % Gagnant.",
})
return (
<main className="bg-gray-50 min-h-screen px-4 sm:px-6 py-10 sm:py-16">
<div className="max-w-5xl mx-auto bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
@@ -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>SIRET :</strong> 362 521 879 00054</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 className="pt-2">
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
à l'adresse contact@thetiptop.fr.
à l'adresse thetiptop.gp4@gmail.com.
</p>
</div>
),

View File

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

View File

@@ -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>
)
}

View File

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

Some files were not shown because too many files have changed in this diff Show More