Compare commits
19 Commits
release-1.
...
bdcdf1a4da
| Author | SHA1 | Date | |
|---|---|---|---|
| bdcdf1a4da | |||
| 79e10946b8 | |||
| 79f463d280 | |||
| a09e9f17b5 | |||
| a40a62d600 | |||
| 12685c1b22 | |||
| 3a0dd25ed2 | |||
| a572fa1a15 | |||
| f9d72d45e2 | |||
|
|
2cf399d6d0 | ||
|
|
e895325254 | ||
|
|
fa21d9af2a | ||
| 6ee3dfdb87 | |||
| d9d2eaba3d | |||
|
|
8adf780da2 | ||
|
|
bd46e0982e | ||
|
|
464d666343 | ||
|
|
20c865a071 | ||
|
|
a2543cf4ce |
6
Jenkinsfile
vendored
6
Jenkinsfile
vendored
@@ -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
|
||||
"
|
||||
'''
|
||||
@@ -229,6 +234,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
|
||||
'''
|
||||
|
||||
@@ -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
1
backend/.gitignore
vendored
@@ -3,3 +3,4 @@ node_modules/
|
||||
.prisma
|
||||
coverage/
|
||||
dist/
|
||||
test-results/
|
||||
|
||||
143
backend/package-lock.json
generated
143
backend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
136
backend/src/config/passport.js
Normal file
136
backend/src/config/passport.js
Normal 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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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.",
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
87
backend/tests/draw.test.js
Normal file
87
backend/tests/draw.test.js
Normal file
@@ -0,0 +1,87 @@
|
||||
const bcrypt = require("bcrypt");
|
||||
const prisma = require("../src/config/prisma");
|
||||
|
||||
// ------------------------
|
||||
// Vérifie que le pool de participants au tirage du gros lot ne compte
|
||||
// chaque client qu'une seule fois, quel que soit son nombre de
|
||||
// participations. On rejoue ici exactement la requête utilisée par
|
||||
// admin.controller.js::drawWinner, restreinte à des utilisateurs dédiés
|
||||
// pour ne pas dépendre des autres données déjà présentes en base
|
||||
// (et surtout ne jamais toucher aux vrais tickets du jeu-concours).
|
||||
// ------------------------
|
||||
|
||||
let userManyParticipations;
|
||||
let userOneParticipation;
|
||||
let ticketIds = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
const lot = await prisma.lot.findFirst();
|
||||
if (!lot) throw new Error("Aucun lot en base : lancez `npx prisma db seed` avant ce test.");
|
||||
|
||||
const hashed = await bcrypt.hash("Password123", 10);
|
||||
|
||||
userManyParticipations = await prisma.user.create({
|
||||
data: {
|
||||
firstname: "Draw",
|
||||
lastname: "MultiParticip",
|
||||
email: "draw-test-multi@test.com",
|
||||
password: hashed,
|
||||
role: "CLIENT",
|
||||
isEmailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
userOneParticipation = await prisma.user.create({
|
||||
data: {
|
||||
firstname: "Draw",
|
||||
lastname: "SingleParticip",
|
||||
email: "draw-test-single@test.com",
|
||||
password: hashed,
|
||||
role: "CLIENT",
|
||||
isEmailVerified: true,
|
||||
},
|
||||
});
|
||||
|
||||
const codes = ["ZZDRAWTEST1", "ZZDRAWTEST2", "ZZDRAWTEST3", "ZZDRAWTEST4"];
|
||||
const owners = [
|
||||
userManyParticipations,
|
||||
userManyParticipations,
|
||||
userManyParticipations,
|
||||
userOneParticipation,
|
||||
];
|
||||
|
||||
for (let i = 0; i < codes.length; i++) {
|
||||
const ticket = await prisma.ticket.create({
|
||||
data: { code: codes[i], lotId: lot.id, isUsed: true, usedAt: new Date(), userId: owners[i].id },
|
||||
});
|
||||
ticketIds.push(ticket.id);
|
||||
await prisma.participation.create({
|
||||
data: { userId: owners[i].id, ticketId: ticket.id, lotId: lot.id },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.participation.deleteMany({ where: { ticketId: { in: ticketIds } } });
|
||||
await prisma.ticket.deleteMany({ where: { id: { in: ticketIds } } });
|
||||
await prisma.user.deleteMany({
|
||||
where: { id: { in: [userManyParticipations.id, userOneParticipation.id] } },
|
||||
});
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
test("un client avec plusieurs participations ne compte qu'une fois dans le pool du tirage", async () => {
|
||||
const eligible = await prisma.user.findMany({
|
||||
where: {
|
||||
role: "CLIENT",
|
||||
id: { in: [userManyParticipations.id, userOneParticipation.id] },
|
||||
participations: { some: {} },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
expect(eligible).toHaveLength(2);
|
||||
expect(eligible.map((u) => u.id).sort()).toEqual(
|
||||
[userManyParticipations.id, userOneParticipation.id].sort()
|
||||
);
|
||||
});
|
||||
106
backend/tests/drawNotification.test.js
Normal file
106
backend/tests/drawNotification.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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")
|
||||
|
||||
113
backend/tests/gainNotification.test.js
Normal file
113
backend/tests/gainNotification.test.js
Normal file
@@ -0,0 +1,113 @@
|
||||
const request = require("supertest");
|
||||
const app = require("../src/app");
|
||||
const prisma = require("../src/config/prisma");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const bcrypt = require("bcrypt");
|
||||
const mailer = require("../src/config/mailer");
|
||||
|
||||
jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() }));
|
||||
|
||||
const DAY = 86400000;
|
||||
const PREFIX = "gain-";
|
||||
const CODES = ["GAINTICK01", "GAINTICK02", "GAINTICK03"];
|
||||
let ctx = {};
|
||||
|
||||
async function cleanup() {
|
||||
const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } });
|
||||
const ids = us.map((u) => u.id);
|
||||
const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } });
|
||||
await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } });
|
||||
await prisma.participation.deleteMany({ where: { userId: { in: ids } } });
|
||||
await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } });
|
||||
await prisma.ticket.deleteMany({ where: { code: { in: CODES } } });
|
||||
await prisma.user.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
|
||||
const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" });
|
||||
const jourJ = (d) =>
|
||||
prisma.gameSettings.update({
|
||||
where: { id: ctx.settingsId },
|
||||
data: { startDate: new Date(Date.now() - d * DAY) },
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
await cleanup();
|
||||
const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } });
|
||||
ctx.settingsId = row.id;
|
||||
ctx.startDateOriginale = row.startDate;
|
||||
|
||||
ctx.lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } });
|
||||
for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: ctx.lot.id } });
|
||||
|
||||
const h = await bcrypt.hash("Password123", 10);
|
||||
ctx.client = await prisma.user.create({ data: { firstname: "Chloe", lastname: "Martin", email: PREFIX + "chloe@test.local", password: h, role: "CLIENT", isEmailVerified: true } });
|
||||
ctx.tClient = tk(ctx.client);
|
||||
|
||||
await jourJ(1);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } });
|
||||
await cleanup();
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
describe("Notification du gain a chaque participation", () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test("le joueur recoit un email decrivant son lot", async () => {
|
||||
mailer.sendMail.mockResolvedValue(true);
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/game/participate")
|
||||
.set("Authorization", "Bearer " + ctx.tClient)
|
||||
.send({ code: CODES[0] })
|
||||
.expect(201);
|
||||
|
||||
expect(res.body.emailSent).toBe(true);
|
||||
expect(mailer.sendMail).toHaveBeenCalledTimes(1);
|
||||
|
||||
const envoi = mailer.sendMail.mock.calls[0][0];
|
||||
expect(envoi.to).toBe(ctx.client.email);
|
||||
expect(envoi.subject).toContain(ctx.lot.name);
|
||||
expect(envoi.html).toContain("Chloe");
|
||||
expect(envoi.html).toContain(CODES[0]);
|
||||
expect(envoi.html).toContain(ctx.lot.name);
|
||||
});
|
||||
|
||||
test("un SMTP en panne n annule pas la participation", async () => {
|
||||
mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible"));
|
||||
|
||||
const res = await request(app)
|
||||
.post("/api/game/participate")
|
||||
.set("Authorization", "Bearer " + ctx.tClient)
|
||||
.send({ code: CODES[1] });
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.emailSent).toBe(false);
|
||||
|
||||
// Le gain reste acquis et consultable.
|
||||
const t = await prisma.ticket.findUnique({ where: { code: CODES[1] } });
|
||||
expect(t.isUsed).toBe(true);
|
||||
|
||||
const gains = await request(app).get("/api/game/my-gains").set("Authorization", "Bearer " + ctx.tClient).expect(200);
|
||||
expect(gains.body.gains.some((g) => g.ticketCode === CODES[1])).toBe(true);
|
||||
});
|
||||
|
||||
test("aucun email si le code est refuse", async () => {
|
||||
mailer.sendMail.mockResolvedValue(true);
|
||||
|
||||
const rejoue = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] });
|
||||
expect(rejoue.status).toBe(409);
|
||||
|
||||
const inconnu = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: "INEXISTAN1" });
|
||||
expect(inconnu.status).toBe(404);
|
||||
|
||||
await jourJ(45);
|
||||
const horsDelai = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[2] });
|
||||
expect(horsDelai.status).toBe(410);
|
||||
await jourJ(1);
|
||||
|
||||
expect(mailer.sendMail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
163
backend/tests/gameFlow.test.js
Normal file
163
backend/tests/gameFlow.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
145
backend/tests/gamePhases.test.js
Normal file
145
backend/tests/gamePhases.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
142
backend/tests/googlePassword.test.js
Normal file
142
backend/tests/googlePassword.test.js
Normal 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;
|
||||
66
backend/tests/register.mail.test.js
Normal file
66
backend/tests/register.mail.test.js
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
909
frontend/package-lock.json
generated
909
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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>} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'email de notification n'a pas pu être envoyé au gagnant. Merci de le contacter directement à l'adresse ci-dessus.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Pas encore de tirage */
|
||||
@@ -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>
|
||||
|
||||
76
frontend/src/pages/AuthCallback.jsx
Normal file
76
frontend/src/pages/AuthCallback.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
133
frontend/src/pages/DefinirMotDePasse.jsx
Normal file
133
frontend/src/pages/DefinirMotDePasse.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
33
frontend/src/services/analytics.js
Normal file
33
frontend/src/services/analytics.js
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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é.'))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user