28 Commits

Author SHA1 Message Date
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
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
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
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
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
59 changed files with 3219 additions and 1003 deletions

109
Jenkinsfile vendored
View File

@@ -49,6 +49,7 @@ pipeline {
env.DEPLOY_NETWORK = 'tiptop-dev'
env.FRONTEND_API_URL = "https://api-dev.dsp5-archi-o24a-4-5-g4.fr"
env.FRONTEND_GA_ID = ""
} else if (env.BRANCH_NAME.startsWith('release-')) {
env.DEPLOY_ENABLED = 'true'
@@ -59,6 +60,7 @@ pipeline {
env.DEPLOY_NETWORK = 'tiptop-preprod'
env.FRONTEND_API_URL = "https://api-preprod.dsp5-archi-o24a-4-5-g4.fr"
env.FRONTEND_GA_ID = ""
} else if (env.BRANCH_NAME == 'main') {
env.DEPLOY_ENABLED = 'true'
@@ -69,6 +71,7 @@ pipeline {
env.DEPLOY_NETWORK = 'tiptop-prod'
env.FRONTEND_API_URL = "https://api.dsp5-archi-o24a-4-5-g4.fr"
env.FRONTEND_GA_ID = "G-4FTNTPJDRJ"
} else {
env.DEPLOY_ENABLED = 'false'
@@ -78,6 +81,7 @@ pipeline {
env.COMPOSE_PROJECT = ''
env.DEPLOY_NETWORK = ''
env.FRONTEND_API_URL = ''
env.FRONTEND_GA_ID = ""
}
echo "Branche : ${env.BRANCH_NAME}"
@@ -163,6 +167,7 @@ pipeline {
npm ci --prefer-offline --no-audit --no-fund &&
npx prisma generate &&
npx prisma migrate deploy &&
npx prisma db seed &&
npm test
"
'''
@@ -175,16 +180,35 @@ pipeline {
allowEmptyResults: true
)
recordCoverage(
tools: [[
parser: 'LCOV',
pattern: 'backend/coverage/lcov.info'
]],
sourceCodeRetention: 'EVERY_BUILD',
id: 'backend-coverage',
name: 'Backend Coverage'
)
publishHTML([
allowMissing: true,
alwaysLinkToLastBuild: true,
keepAll: true,
reportDir: 'backend/coverage',
reportFiles: 'index.html',
reportName: 'Backend Coverage HTML'
])
archiveArtifacts(
artifacts: 'backend/coverage/**',
fingerprint: true,
allowEmptyArchive: true
)
}
}
}
}
stage('Frontend - Lint et Build') {
stage('Frontend - Lint, Tests et Build') {
steps {
sh '''
docker run --rm \
@@ -208,12 +232,92 @@ pipeline {
sh -c "
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 {
@@ -229,6 +333,7 @@ pipeline {
docker build \
--build-arg VITE_API_URL="${FRONTEND_API_URL}" \
--build-arg VITE_GA_ID="${FRONTEND_GA_ID}" \
-t "${FRONTEND_IMAGE}:${IMAGE_TAG}" \
frontend
'''

View File

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

1
backend/.gitignore vendored
View File

@@ -3,3 +3,4 @@ node_modules/
.prisma
coverage/
dist/
test-results/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -62,6 +62,12 @@ const register = async (req, res) => {
},
});
// 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,
@@ -78,6 +84,20 @@ const register = async (req, res) => {
</div>
`,
});
} catch (mailError) {
console.error("Erreur envoi du code de validation:", mailError);
try {
await prisma.user.delete({ where: { id: user.id } });
} catch (rollbackError) {
console.error("Echec de l'annulation de l'inscription:", rollbackError);
}
return res.status(502).json({
message:
"Votre compte n'a pas pu être créé : l'email de validation n'a pas pu être envoyé. Merci de réessayer dans quelques instants.",
});
}
return res.status(201).json({
message: "Inscription réussie. Un code de validation a été envoyé par email.",
@@ -156,6 +176,14 @@ const login = async (req, res) => {
});
}
// Compte créé via un provider externe : aucun mot de passe à comparer.
if (!user.password) {
const providerLabel = user.provider === "FACEBOOK" ? "Facebook" : "Google";
return res.status(401).json({
message: `Ce compte utilise la connexion ${providerLabel}. Utilisez le bouton « ${providerLabel} » pour vous connecter.`,
});
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
@@ -224,7 +252,10 @@ const forgotPassword = async (req, res) => {
},
});
const resetUrl = `${process.env.APP_URL}/api/auth/reset-password/${resetPasswordToken}`;
// Le lien doit ouvrir le formulaire du front, pas la route POST de l'API :
// un clic depuis un email est une requête GET, qui tombait en 404.
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
const resetUrl = `${frontendUrl}/definir-mot-de-passe/${resetPasswordToken}`;
await transporter.sendMail({
from: process.env.MAIL_FROM,
@@ -255,7 +286,7 @@ const forgotPassword = async (req, res) => {
const resetPassword = async (req, res) => {
try {
const { token } = req.params;
const { password } = req.body;
const { password, confirmPassword } = req.body;
if (!password || password.length < 8) {
return res.status(400).json({
@@ -263,6 +294,14 @@ const resetPassword = async (req, res) => {
});
}
// La confirmation est vérifiée côté serveur aussi : le contrôle du
// navigateur ne protège pas un appel direct à l'API.
if (confirmPassword !== undefined && password !== confirmPassword) {
return res.status(400).json({
message: "Les deux mots de passe ne correspondent pas.",
});
}
const user = await prisma.user.findFirst({
where: {
resetPasswordToken: token,
@@ -354,6 +393,50 @@ const verifyCode = async (req, res) => {
}
};
// Fin du parcours OAuth : passport a posé l'utilisateur sur req.user, on émet
// le même JWT que la connexion classique et on renvoie le navigateur vers le
// front, qui récupère le token dans l'URL.
const oauthCallback = (req, res) => {
const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173";
try {
const user = req.user;
if (!user) {
return res.redirect(
`${frontendUrl}/auth/callback?error=${encodeURIComponent(
"Connexion externe refusée."
)}`
);
}
const token = jwt.sign(
{ id: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "24h" }
);
const payload = Buffer.from(
JSON.stringify({
id: user.id,
firstname: user.firstname,
lastname: user.lastname,
email: user.email,
role: user.role,
})
).toString("base64");
return res.redirect(`${frontendUrl}/auth/callback?token=${token}&user=${payload}`);
} catch (error) {
console.error("Erreur oauthCallback:", error);
return res.redirect(
`${frontendUrl}/auth/callback?error=${encodeURIComponent(
"Erreur serveur lors de la connexion externe."
)}`
);
}
};
const me = async (req, res) => {
return res.json({
user: req.user,
@@ -383,10 +466,13 @@ const deleteAccount = async (req, res) => {
// 4. Supprimer les participations
await tx.participation.deleteMany({ where: { userId } });
// 5. Libérer les tickets utilisés par ce user
// 5. Détacher les tickets du compte sans les remettre en jeu : un code
// figure sur un ticket de caisse physique et n'est jouable qu'une
// fois. Le réinitialiser permettrait de rejouer indéfiniment le même
// code en supprimant puis recréant un compte.
await tx.ticket.updateMany({
where: { userId },
data: { userId: null, isUsed: false, usedAt: null },
data: { userId: null },
});
// 6. Supprimer l'utilisateur
@@ -415,6 +501,13 @@ const changePassword = async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user.password) {
return res.status(400).json({
message:
"Ce compte utilise une connexion externe et n'a pas de mot de passe à modifier.",
});
}
const isValid = await bcrypt.compare(currentPassword, user.password);
if (!isValid) {
return res.status(401).json({ message: "Mot de passe actuel incorrect." });
@@ -440,4 +533,5 @@ module.exports = {
forgotPassword,
changePassword,
deleteAccount,
oauthCallback,
};

View File

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

View File

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

View File

@@ -23,6 +23,8 @@ const authMiddleware = async (req, res, next) => {
lastname: true,
email: true,
role: true,
provider: true,
password: true,
},
});
@@ -32,7 +34,11 @@ const authMiddleware = async (req, res, next) => {
});
}
req.user = user;
// req.user est renvoyé tel quel par /api/auth/me : on remplace le hash par
// un simple booléen, qui suffit au profil pour proposer « définir » ou
// « modifier » son mot de passe.
const { password, ...rest } = user;
req.user = { ...rest, hasPassword: Boolean(password) };
next();
} catch (error) {
return res.status(401).json({

View File

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

View File

@@ -1,8 +1,9 @@
const express = require("express");
const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount } = require("../controllers/auth.controller");
const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller");
const authMiddleware = require("../middlewares/auth.middleware");
const validate = require("../middlewares/validate.middleware");
const { registerSchema, loginSchema } = require("../validations/auth.validation");
const { passport, googleEnabled } = require("../config/passport");
const router = express.Router();
@@ -161,4 +162,55 @@ router.post("/verify-code", verifyCode);
router.patch("/change-password", authMiddleware, changePassword);
router.delete("/account", authMiddleware, deleteAccount);
/**
* @swagger
* /api/auth/google:
* get:
* summary: Démarre la connexion via Google (redirection OAuth 2.0)
* tags:
* - Auth
* responses:
* 302:
* description: Redirection vers l'écran de consentement Google.
* 503:
* description: Connexion Google non configurée sur ce serveur.
*/
/**
* @swagger
* /api/auth/google/callback:
* get:
* summary: Retour de Google, émet le JWT et redirige vers le front
* tags:
* - Auth
* responses:
* 302:
* description: Redirection vers FRONTEND_URL/auth/callback avec le token.
*/
if (googleEnabled) {
router.get(
"/google",
passport.authenticate("google", { scope: ["profile", "email"], session: false })
);
router.get(
"/google/callback",
passport.authenticate("google", {
session: false,
failureRedirect: `${process.env.FRONTEND_URL || "http://localhost:5173"}/auth/callback?error=${encodeURIComponent(
"Connexion Google refusée."
)}`,
}),
oauthCallback
);
} else {
// Sans identifiants configurés, on répond explicitement plutôt que de
// laisser passport planter au démarrage.
const notConfigured = (req, res) =>
res.status(503).json({ message: "La connexion Google n'est pas configurée sur ce serveur." });
router.get("/google", notConfigured);
router.get("/google/callback", notConfigured);
}
module.exports = router;

View File

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

View File

@@ -1,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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

4
frontend/.gitignore vendored
View File

@@ -22,3 +22,7 @@ dist-ssr
*.njsproj
*.sln
*.sw?
# Tests / coverage
coverage/
test-results/

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,8 @@
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"dependencies": {
"react": "^19.2.6",
@@ -25,6 +26,7 @@
"@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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,14 +4,29 @@ import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://localhost:5000',
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/setupTests.js',
// Jenkins pourra lire directement ce fichier.
reporters: ['default', 'junit'],
outputFile: {
junit: './test-results/junit.xml',
},
// Rapports utilisés ensuite par Jenkins et SonarQube.
coverage: {
provider: 'v8',
reporter: ['text', 'html', 'lcov'],
reportsDirectory: './coverage',
},
},
})