merge: integrate Google OAuth and latest application fixes
This commit is contained in:
@@ -5,8 +5,17 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db"
|
|||||||
JWT_SECRET="your_secret_key"
|
JWT_SECRET="your_secret_key"
|
||||||
|
|
||||||
APP_URL="http://localhost:5000"
|
APP_URL="http://localhost:5000"
|
||||||
|
FRONTEND_URL="http://localhost:5173"
|
||||||
MAIL_HOST="smtp.gmail.com"
|
MAIL_HOST="smtp.gmail.com"
|
||||||
MAIL_PORT="587"
|
MAIL_PORT="587"
|
||||||
MAIL_USER="your_email@gmail.com"
|
MAIL_USER=abayomiandil62@gmail.com
|
||||||
MAIL_PASS="your_app_password"
|
MAIL_PASS=dumqamoupdrliygf
|
||||||
MAIL_FROM="Thé Tip Top <your_email@gmail.com>"
|
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=""
|
||||||
|
|||||||
3
backend/.gitignore
vendored
3
backend/.gitignore
vendored
@@ -2,4 +2,5 @@ node_modules/
|
|||||||
.env
|
.env
|
||||||
.prisma
|
.prisma
|
||||||
coverage/
|
coverage/
|
||||||
dist/
|
dist/
|
||||||
|
test-results/
|
||||||
|
|||||||
143
backend/package-lock.json
generated
143
backend/package-lock.json
generated
@@ -13,8 +13,12 @@
|
|||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.6.1",
|
||||||
|
"helmet": "^8.3.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"swagger-jsdoc": "^6.3.0",
|
"swagger-jsdoc": "^6.3.0",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
@@ -2197,6 +2201,15 @@
|
|||||||
"node": "18 || 20 || >=22"
|
"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": {
|
"node_modules/baseline-browser-mapping": {
|
||||||
"version": "2.10.40",
|
"version": "2.10.40",
|
||||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
|
||||||
@@ -3210,6 +3223,25 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/exsolve": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||||
@@ -3658,6 +3690,18 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
@@ -3766,6 +3810,15 @@
|
|||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||||
@@ -5487,9 +5540,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "9.0.3",
|
"version": "9.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
|
||||||
"integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==",
|
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -5608,6 +5661,12 @@
|
|||||||
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
"integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/object-assign": {
|
||||||
"version": "4.1.1",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||||
@@ -5768,6 +5827,64 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -5829,6 +5946,11 @@
|
|||||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/perfect-debounce": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
|
||||||
@@ -6774,6 +6896,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/undefsafe": {
|
||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
|
||||||
@@ -6866,6 +6994,15 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"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": {
|
"node_modules/uuid": {
|
||||||
"version": "14.0.1",
|
"version": "14.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz",
|
||||||
|
|||||||
@@ -20,8 +20,12 @@
|
|||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
"express-rate-limit": "^8.6.1",
|
||||||
|
"helmet": "^8.3.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
|
"passport": "^0.7.0",
|
||||||
|
"passport-google-oauth20": "^2.0.0",
|
||||||
"prisma": "^6.19.3",
|
"prisma": "^6.19.3",
|
||||||
"swagger-jsdoc": "^6.3.0",
|
"swagger-jsdoc": "^6.3.0",
|
||||||
"swagger-ui-express": "^5.0.1",
|
"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
|
CANCELLED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AuthProvider {
|
||||||
|
LOCAL
|
||||||
|
GOOGLE
|
||||||
|
FACEBOOK
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
firstname String
|
firstname String
|
||||||
lastname String
|
lastname String
|
||||||
email String @unique
|
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)
|
role Role @default(CLIENT)
|
||||||
birthdate DateTime?
|
birthdate DateTime?
|
||||||
gender String?
|
gender String?
|
||||||
@@ -43,10 +51,16 @@ model User {
|
|||||||
resetPasswordToken String?
|
resetPasswordToken String?
|
||||||
resetPasswordExpires DateTime?
|
resetPasswordExpires DateTime?
|
||||||
|
|
||||||
|
provider AuthProvider @default(LOCAL)
|
||||||
|
providerId String?
|
||||||
|
avatarUrl String?
|
||||||
|
|
||||||
tickets Ticket[]
|
tickets Ticket[]
|
||||||
participations Participation[]
|
participations Participation[]
|
||||||
grandPrizeWinner GrandPrizeWinner?
|
grandPrizeWinner GrandPrizeWinner?
|
||||||
claimsValidated Claim[] @relation("EmployeeClaims")
|
claimsValidated Claim[] @relation("EmployeeClaims")
|
||||||
|
|
||||||
|
@@unique([provider, providerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
model Lot {
|
model Lot {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const cors = require("cors");
|
const cors = require("cors");
|
||||||
|
const helmet = require("helmet");
|
||||||
|
const rateLimit = require("express-rate-limit");
|
||||||
require("dotenv").config();
|
require("dotenv").config();
|
||||||
|
|
||||||
const authRoutes = require("./routes/auth.routes");
|
const authRoutes = require("./routes/auth.routes");
|
||||||
@@ -9,11 +11,37 @@ const adminRoutes = require("./routes/admin.routes");
|
|||||||
|
|
||||||
const swaggerUi = require("swagger-ui-express");
|
const swaggerUi = require("swagger-ui-express");
|
||||||
const swaggerSpec = require("./docs/swagger");
|
const swaggerSpec = require("./docs/swagger");
|
||||||
|
const { passport } = require("./config/passport");
|
||||||
|
|
||||||
const app = express();
|
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());
|
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
|
// routes
|
||||||
app.use("/api/auth", authRoutes);
|
app.use("/api/auth", authRoutes);
|
||||||
@@ -22,7 +50,7 @@ app.use("/api/employee", employeeRoutes);
|
|||||||
app.use("/api/admin", adminRoutes);
|
app.use("/api/admin", adminRoutes);
|
||||||
|
|
||||||
// swagger
|
// swagger
|
||||||
app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
app.use("/api-docs", helmet({ contentSecurityPolicy: false }), swaggerUi.serve, swaggerUi.setup(swaggerSpec));
|
||||||
|
|
||||||
// test route
|
// test route
|
||||||
app.get("/", (req, res) => {
|
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 bcrypt = require("bcrypt");
|
||||||
|
const crypto = require("crypto");
|
||||||
const prisma = require("../config/prisma");
|
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+"];
|
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) => {
|
const getStats = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await expireOverdueClaims();
|
|
||||||
const gameWindow = await getGameWindow();
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
const [
|
const [
|
||||||
@@ -79,9 +80,11 @@ const getStats = async (req, res) => {
|
|||||||
ticketsUsed,
|
ticketsUsed,
|
||||||
ticketsRemaining: ticketsTotal - ticketsUsed,
|
ticketsRemaining: ticketsTotal - ticketsUsed,
|
||||||
participations,
|
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,
|
claimsDelivered,
|
||||||
claimsCancelled,
|
claimsCancelled: gameWindow.isOpen ? claimsCancelled : claimsCancelled + claimsPending,
|
||||||
users,
|
users,
|
||||||
gameWindow,
|
gameWindow,
|
||||||
ageStats: computeAgeStats(clientsWithBirthdate),
|
ageStats: computeAgeStats(clientsWithBirthdate),
|
||||||
@@ -214,6 +217,31 @@ const createEmployee = async (req, res) => {
|
|||||||
|
|
||||||
const drawWinner = async (req, res) => {
|
const drawWinner = async (req, res) => {
|
||||||
try {
|
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({
|
const existingWinner = await prisma.grandPrizeWinner.findFirst({
|
||||||
include: {
|
include: {
|
||||||
user: {
|
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({
|
const participants = await prisma.user.findMany({
|
||||||
where: {
|
where: {
|
||||||
role: "CLIENT",
|
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 winner = participants[randomIndex];
|
||||||
|
|
||||||
const savedWinner = await prisma.grandPrizeWinner.create({
|
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({
|
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,
|
totalEligibleParticipants: participants.length,
|
||||||
|
emailSent,
|
||||||
winner: savedWinner,
|
winner: savedWinner,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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) => {
|
const getGrandPrizeWinner = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const winner = await prisma.grandPrizeWinner.findFirst({
|
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({
|
return res.json({
|
||||||
winner,
|
winner: winner ?? null,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Erreur récupération gagnant:", error);
|
console.error("Erreur récupération gagnant:", error);
|
||||||
@@ -326,4 +434,6 @@ module.exports = {
|
|||||||
createEmployee,
|
createEmployee,
|
||||||
drawWinner,
|
drawWinner,
|
||||||
getGrandPrizeWinner,
|
getGrandPrizeWinner,
|
||||||
|
setGameSettings,
|
||||||
|
getGameSettings,
|
||||||
};
|
};
|
||||||
@@ -62,11 +62,17 @@ const register = async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await transporter.sendMail({
|
// Le code de validation est le seul moyen d'activer le compte. Si l'envoi
|
||||||
from: process.env.MAIL_FROM,
|
// échoue, laisser l'utilisateur en base le bloquerait définitivement : il
|
||||||
to: email,
|
// ne pourrait ni se connecter (email non vérifié) ni se réinscrire (email
|
||||||
subject: "Votre code de validation Thé Tip Top",
|
// déjà pris). On annule donc la création pour que la tentative reste
|
||||||
html: `
|
// rejouable.
|
||||||
|
try {
|
||||||
|
await transporter.sendMail({
|
||||||
|
from: process.env.MAIL_FROM,
|
||||||
|
to: email,
|
||||||
|
subject: "Votre code de validation Thé Tip Top",
|
||||||
|
html: `
|
||||||
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||||
<h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
|
<h2 style="color:#006B6B">Bienvenue ${firstname} !</h2>
|
||||||
<p>Merci pour votre inscription sur Thé Tip Top.</p>
|
<p>Merci pour votre inscription sur Thé Tip Top.</p>
|
||||||
@@ -77,7 +83,21 @@ const register = async (req, res) => {
|
|||||||
<p style="color:#888;font-size:13px">Ce code expire dans <strong>15 minutes</strong>.</p>
|
<p style="color:#888;font-size:13px">Ce code expire dans <strong>15 minutes</strong>.</p>
|
||||||
</div>
|
</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({
|
return res.status(201).json({
|
||||||
message: "Inscription réussie. Un code de validation a été envoyé par email.",
|
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);
|
const isPasswordValid = await bcrypt.compare(password, user.password);
|
||||||
|
|
||||||
if (!isPasswordValid) {
|
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({
|
await transporter.sendMail({
|
||||||
from: process.env.MAIL_FROM,
|
from: process.env.MAIL_FROM,
|
||||||
@@ -255,7 +286,7 @@ const forgotPassword = async (req, res) => {
|
|||||||
const resetPassword = async (req, res) => {
|
const resetPassword = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { token } = req.params;
|
const { token } = req.params;
|
||||||
const { password } = req.body;
|
const { password, confirmPassword } = req.body;
|
||||||
|
|
||||||
if (!password || password.length < 8) {
|
if (!password || password.length < 8) {
|
||||||
return res.status(400).json({
|
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({
|
const user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
resetPasswordToken: token,
|
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) => {
|
const me = async (req, res) => {
|
||||||
return res.json({
|
return res.json({
|
||||||
user: req.user,
|
user: req.user,
|
||||||
@@ -383,10 +466,13 @@ const deleteAccount = async (req, res) => {
|
|||||||
// 4. Supprimer les participations
|
// 4. Supprimer les participations
|
||||||
await tx.participation.deleteMany({ where: { userId } });
|
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({
|
await tx.ticket.updateMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
data: { userId: null, isUsed: false, usedAt: null },
|
data: { userId: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. Supprimer l'utilisateur
|
// 6. Supprimer l'utilisateur
|
||||||
@@ -415,6 +501,13 @@ const changePassword = async (req, res) => {
|
|||||||
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
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);
|
const isValid = await bcrypt.compare(currentPassword, user.password);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return res.status(401).json({ message: "Mot de passe actuel incorrect." });
|
return res.status(401).json({ message: "Mot de passe actuel incorrect." });
|
||||||
@@ -440,4 +533,5 @@ module.exports = {
|
|||||||
forgotPassword,
|
forgotPassword,
|
||||||
changePassword,
|
changePassword,
|
||||||
deleteAccount,
|
deleteAccount,
|
||||||
|
oauthCallback,
|
||||||
};
|
};
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
const prisma = require("../config/prisma");
|
const prisma = require("../config/prisma");
|
||||||
const { expireOverdueClaims } = require("../utils/gameWindow");
|
const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow");
|
||||||
|
|
||||||
const getGainByTicketCode = async (req, res) => {
|
const getGainByTicketCode = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { code } = req.params;
|
const { code } = req.params;
|
||||||
|
|
||||||
await expireOverdueClaims();
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
const ticket = await prisma.ticket.findUnique({
|
const ticket = await prisma.ticket.findUnique({
|
||||||
where: { code: code.toUpperCase() },
|
where: { code: code.toUpperCase() },
|
||||||
@@ -52,7 +52,7 @@ const getGainByTicketCode = async (req, res) => {
|
|||||||
},
|
},
|
||||||
claim: {
|
claim: {
|
||||||
id: ticket.participation.claim.id,
|
id: ticket.participation.claim.id,
|
||||||
status: ticket.participation.claim.status,
|
status: effectiveClaimStatus(ticket.participation.claim.status, gameWindow),
|
||||||
claimedAt: ticket.participation.claim.claimedAt,
|
claimedAt: ticket.participation.claim.claimedAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -69,7 +69,7 @@ const deliverClaim = async (req, res) => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const employeeId = req.user.id;
|
const employeeId = req.user.id;
|
||||||
|
|
||||||
await expireOverdueClaims();
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
const claim = await prisma.claim.findUnique({
|
const claim = await prisma.claim.findUnique({
|
||||||
where: { id: Number(id) },
|
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({
|
return res.status(400).json({
|
||||||
message: "Ce gain est expiré et ne peut plus être remis.",
|
message: "Ce gain est expiré et ne peut plus être remis.",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
const prisma = require("../config/prisma");
|
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) => {
|
const participate = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -7,9 +15,13 @@ const participate = async (req, res) => {
|
|||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
const gameWindow = await getGameWindow();
|
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({
|
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 result = await prisma.$transaction(async (tx) => {
|
||||||
const updatedTicket = await tx.ticket.update({
|
// Le contrôle isUsed ci-dessus ne suffit pas : deux requêtes simultanées
|
||||||
where: { id: ticket.id },
|
// 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: {
|
data: {
|
||||||
isUsed: true,
|
isUsed: true,
|
||||||
usedAt: new Date(),
|
usedAt: new Date(),
|
||||||
userId,
|
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({
|
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({
|
return res.status(201).json({
|
||||||
message: "Participation enregistrée avec succès.",
|
message: "Participation enregistrée avec succès.",
|
||||||
|
emailSent,
|
||||||
gain: {
|
gain: {
|
||||||
name: result.ticket.lot.name,
|
name: result.ticket.lot.name,
|
||||||
description: result.ticket.lot.description,
|
description: result.ticket.lot.description,
|
||||||
@@ -80,6 +133,12 @@ const participate = async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof TicketDejaUtiliseError) {
|
||||||
|
return res.status(409).json({
|
||||||
|
message: "Ce code a déjà été utilisé.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.error("Erreur participation:", error);
|
console.error("Erreur participation:", error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
message: "Erreur serveur lors de la participation.",
|
message: "Erreur serveur lors de la participation.",
|
||||||
@@ -91,7 +150,7 @@ const myGains = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
await expireOverdueClaims();
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
const participations = await prisma.participation.findMany({
|
const participations = await prisma.participation.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -112,7 +171,9 @@ const myGains = async (req, res) => {
|
|||||||
lot: participation.lot.name,
|
lot: participation.lot.name,
|
||||||
description: participation.lot.description,
|
description: participation.lot.description,
|
||||||
value: participation.lot.value,
|
value: participation.lot.value,
|
||||||
claimStatus: participation.claim?.status,
|
claimStatus: participation.claim
|
||||||
|
? effectiveClaimStatus(participation.claim.status, gameWindow)
|
||||||
|
: undefined,
|
||||||
wonAt: participation.createdAt,
|
wonAt: participation.createdAt,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ const authMiddleware = async (req, res, next) => {
|
|||||||
lastname: true,
|
lastname: true,
|
||||||
email: true,
|
email: true,
|
||||||
role: 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();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ const {
|
|||||||
createEmployee,
|
createEmployee,
|
||||||
drawWinner,
|
drawWinner,
|
||||||
getGrandPrizeWinner,
|
getGrandPrizeWinner,
|
||||||
|
setGameSettings,
|
||||||
|
getGameSettings,
|
||||||
} = require("../controllers/admin.controller");
|
} = require("../controllers/admin.controller");
|
||||||
|
|
||||||
const authMiddleware = require("../middlewares/auth.middleware");
|
const authMiddleware = require("../middlewares/auth.middleware");
|
||||||
const roleMiddleware = require("../middlewares/role.middleware");
|
const roleMiddleware = require("../middlewares/role.middleware");
|
||||||
const validate = require("../middlewares/validate.middleware");
|
const validate = require("../middlewares/validate.middleware");
|
||||||
const { createEmployeeSchema } = require("../validations/admin.validation");
|
const { createEmployeeSchema, gameSettingsSchema } = require("../validations/admin.validation");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -105,10 +107,48 @@ router.post("/draw", drawWinner);
|
|||||||
* - bearerAuth: []
|
* - bearerAuth: []
|
||||||
* responses:
|
* responses:
|
||||||
* 200:
|
* 200:
|
||||||
* description: Gagnant du gros lot
|
* description: Gagnant du gros lot, ou { winner - null } si aucun tirage n'a encore eu lieu
|
||||||
* 404:
|
|
||||||
* description: Aucun gagnant encore tiré
|
|
||||||
*/
|
*/
|
||||||
router.get("/draw", getGrandPrizeWinner);
|
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;
|
module.exports = router;
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
const express = require("express");
|
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 authMiddleware = require("../middlewares/auth.middleware");
|
||||||
const validate = require("../middlewares/validate.middleware");
|
const validate = require("../middlewares/validate.middleware");
|
||||||
const { registerSchema, loginSchema } = require("../validations/auth.validation");
|
const { registerSchema, loginSchema } = require("../validations/auth.validation");
|
||||||
|
const { passport, googleEnabled } = require("../config/passport");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -161,4 +162,55 @@ router.post("/verify-code", verifyCode);
|
|||||||
router.patch("/change-password", authMiddleware, changePassword);
|
router.patch("/change-password", authMiddleware, changePassword);
|
||||||
router.delete("/account", authMiddleware, deleteAccount);
|
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;
|
module.exports = router;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const { participate, myGains } = require("../controllers/game.controller");
|
const { participate, myGains } = require("../controllers/game.controller");
|
||||||
const authMiddleware = require("../middlewares/auth.middleware");
|
const authMiddleware = require("../middlewares/auth.middleware");
|
||||||
|
const roleMiddleware = require("../middlewares/role.middleware");
|
||||||
const validate = require("../middlewares/validate.middleware");
|
const validate = require("../middlewares/validate.middleware");
|
||||||
const { participateSchema } = require("../validations/game.validation");
|
const { participateSchema } = require("../validations/game.validation");
|
||||||
|
|
||||||
@@ -20,7 +21,9 @@ const router = express.Router();
|
|||||||
* description: Participation enregistrée
|
* 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
|
* @swagger
|
||||||
@@ -36,6 +39,6 @@ router.post("/participate", authMiddleware, validate(participateSchema), partici
|
|||||||
* description: Liste des gains
|
* description: Liste des gains
|
||||||
*/
|
*/
|
||||||
|
|
||||||
router.get("/my-gains", authMiddleware, myGains);
|
router.get("/my-gains", authMiddleware, roleMiddleware("CLIENT"), myGains);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -1,23 +1,41 @@
|
|||||||
const prisma = require("../config/prisma");
|
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 GAME_DURATION_DAYS = 30;
|
||||||
const CLAIM_EXTENSION_DAYS = 30;
|
const CLAIM_EXTENSION_DAYS = 30;
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
// Fenêtre pendant laquelle un joueur peut à la fois tester son code et
|
// Deux échéances distinctes à partir de startDate :
|
||||||
// réclamer son lot : 30 jours de jeu + 30 jours supplémentaires à compter
|
// - participationDeadline (30 j) : dernier moment pour tester un code ;
|
||||||
// de startDate. Tant qu'aucune GameSettings n'existe, le jeu est considéré
|
// - claimDeadline (60 j) : dernier moment pour retirer un lot déjà gagné.
|
||||||
// comme non démarré et donc ouvert (comportement de dev par défaut).
|
// Les 30 jours supplémentaires servent uniquement au retrait, pas au jeu.
|
||||||
async function getGameWindow() {
|
// 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" } });
|
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) {
|
if (!settings) {
|
||||||
return {
|
return {
|
||||||
configured: false,
|
configured: false,
|
||||||
|
phase: PHASES.NOT_CONFIGURED,
|
||||||
startDate: null,
|
startDate: null,
|
||||||
participationDeadline: null,
|
participationDeadline: null,
|
||||||
claimDeadline: 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
|
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 {
|
return {
|
||||||
configured: true,
|
configured: true,
|
||||||
|
phase,
|
||||||
startDate: settings.startDate,
|
startDate: settings.startDate,
|
||||||
participationDeadline,
|
participationDeadline,
|
||||||
claimDeadline,
|
claimDeadline,
|
||||||
isOpen: new Date() <= claimDeadline,
|
hasStarted,
|
||||||
|
isParticipationOpen,
|
||||||
|
isOpen,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bascule en CANCELLED les gains PENDING non réclamés une fois la fenêtre
|
// L'expiration d'un gain est *dérivée* de la fenêtre de jeu, jamais écrite en
|
||||||
// fermée. Appelé au fil de l'eau (pas de cron) avant toute lecture/écriture
|
// base : un PENDING dont le délai de retrait est passé est présenté comme
|
||||||
// de claims, ce qui suffit pour ce projet.
|
// CANCELLED, mais la ligne reste intacte. Corriger une startDate erronée
|
||||||
async function expireOverdueClaims() {
|
// suffit donc à rétablir les gains, et aucune lecture ne modifie la base.
|
||||||
const window = await getGameWindow();
|
function effectiveClaimStatus(status, gameWindow) {
|
||||||
if (window.isOpen) return;
|
if (status === "PENDING" && !gameWindow.isOpen) return "CANCELLED";
|
||||||
|
return status;
|
||||||
await prisma.claim.updateMany({
|
|
||||||
where: { status: "PENDING" },
|
|
||||||
data: { status: "CANCELLED" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(),
|
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 = {
|
module.exports = {
|
||||||
createEmployeeSchema,
|
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,125 +10,88 @@ 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 () => {
|
beforeAll(async () => {
|
||||||
await prisma.claim.deleteMany();
|
await prisma.user.deleteMany({ where: { email: testEmail } });
|
||||||
await prisma.participation.deleteMany();
|
await prisma.ticket.deleteMany({ where: { code: ticketCode } });
|
||||||
await prisma.ticket.deleteMany();
|
|
||||||
await prisma.user.deleteMany();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
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();
|
await prisma.$disconnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("THE TIP TOP - FULL E2E FLOW", () => {
|
describe("THE TIP TOP - FULL E2E FLOW", () => {
|
||||||
let token;
|
|
||||||
let ticketCode = "ABC123";
|
|
||||||
|
|
||||||
// ------------------------
|
|
||||||
// 1. REGISTER
|
|
||||||
// ------------------------
|
|
||||||
test("Register user", async () => {
|
test("Register user", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app).post("/api/auth/register").send({
|
||||||
.post("/api/auth/register")
|
firstname: "E2E",
|
||||||
.send({
|
lastname: "Test",
|
||||||
firstname: "Test",
|
email: testEmail,
|
||||||
lastname: "User",
|
password: "Password123",
|
||||||
email: "test@test.com",
|
});
|
||||||
password: "Password123",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect([201, 409]).toContain(res.statusCode);
|
expect(res.statusCode).toBe(201);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------
|
|
||||||
// 2. LOGIN
|
|
||||||
// ------------------------
|
|
||||||
test("Login user", async () => {
|
test("Login user", async () => {
|
||||||
// Validation du user pour bypasser vérification email
|
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { email: "test@test.com" },
|
where: { email: testEmail },
|
||||||
data: { isEmailVerified: true },
|
data: { isEmailVerified: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await request(app)
|
const res = await request(app).post("/api/auth/login").send({
|
||||||
.post("/api/auth/login")
|
email: testEmail,
|
||||||
.send({
|
password: "Password123",
|
||||||
email: "test@test.com",
|
});
|
||||||
password: "Password123",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.body.token).toBeDefined();
|
expect(res.body.token).toBeDefined();
|
||||||
token = res.body.token;
|
token = res.body.token;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------
|
test("Create a dedicated ticket for this test", async () => {
|
||||||
// 3. PARTICIPATE GAME
|
const lot = await prisma.lot.findFirst();
|
||||||
// ------------------------
|
expect(lot).not.toBeNull();
|
||||||
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
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ticket = await prisma.ticket.create({
|
const ticket = await prisma.ticket.create({
|
||||||
data: {
|
data: { code: ticketCode, lotId: lot.id },
|
||||||
code: ticketCode,
|
|
||||||
lotId: lot.id
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
createdTicketId = ticket.id;
|
||||||
|
|
||||||
expect(ticket).toBeDefined();
|
|
||||||
expect(ticket.code).toBe(ticketCode);
|
expect(ticket.code).toBe(ticketCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ------------------------
|
|
||||||
// 4. PARTICIPATE GAME
|
|
||||||
// ------------------------
|
|
||||||
test("Participate to game", async () => {
|
test("Participate to game", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.post("/api/game/participate")
|
.post("/api/game/participate")
|
||||||
.set("Authorization", `Bearer ${token}`)
|
.set("Authorization", `Bearer ${token}`)
|
||||||
.send({ code: ticketCode });
|
.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 () => {
|
test("Get my gains", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.get("/api/game/my-gains")
|
.get("/api/game/my-gains")
|
||||||
@@ -136,11 +99,9 @@ describe("THE TIP TOP - FULL E2E FLOW", () => {
|
|||||||
|
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
expect(res.body.gains).toBeDefined();
|
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 () => {
|
test("Admin stats - CLIENT access denied", async () => {
|
||||||
const res = await request(app)
|
const res = await request(app)
|
||||||
.get("/api/admin/stats")
|
.get("/api/admin/stats")
|
||||||
@@ -149,4 +110,4 @@ describe("THE TIP TOP - FULL E2E FLOW", () => {
|
|||||||
expect(res.statusCode).toBe(403);
|
expect(res.statusCode).toBe(403);
|
||||||
expect(res.body.message || res.body).toBeDefined();
|
expect(res.body.message || res.body).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
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();
|
|
||||||
});
|
|
||||||
@@ -36,8 +36,25 @@ services:
|
|||||||
NODE_ENV: development
|
NODE_ENV: development
|
||||||
DATABASE_URL: postgresql://tiptop:tiptop_dev_password@postgres:5432/the_tip_top_db
|
DATABASE_URL: postgresql://tiptop:tiptop_dev_password@postgres:5432/the_tip_top_db
|
||||||
JWT_SECRET: dev_secret_change_me
|
JWT_SECRET: dev_secret_change_me
|
||||||
|
<<<<<<< HEAD
|
||||||
APP_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr
|
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
|
API_PUBLIC_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr
|
||||||
|
=======
|
||||||
|
APP_URL: http://192.168.1.23:5001
|
||||||
|
FRONTEND_URL: http://192.168.1.23:5173
|
||||||
|
MAIL_HOST: smtp.gmail.com
|
||||||
|
MAIL_PORT: 587
|
||||||
|
MAIL_USER: ""
|
||||||
|
MAIL_PASS: ""
|
||||||
|
MAIL_FROM: "The Tip Top <no-reply@tiptop.local>"
|
||||||
|
>>>>>>> github/Emmanuel
|
||||||
|
|
||||||
|
# Injectés par le pipeline, jamais commités. Vides, les routes de
|
||||||
|
# connexion externe répondent 503 sans bloquer le démarrage.
|
||||||
|
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}"
|
||||||
|
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
|
||||||
|
FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}"
|
||||||
|
FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}"
|
||||||
|
|
||||||
command: >
|
command: >
|
||||||
sh -c "
|
sh -c "
|
||||||
|
|||||||
@@ -36,8 +36,25 @@ services:
|
|||||||
NODE_ENV: preproduction
|
NODE_ENV: preproduction
|
||||||
DATABASE_URL: postgresql://tiptop_preprod:tiptop_preprod_password@postgres:5432/the_tip_top_preprod
|
DATABASE_URL: postgresql://tiptop_preprod:tiptop_preprod_password@postgres:5432/the_tip_top_preprod
|
||||||
JWT_SECRET: preprod_secret_change_me
|
JWT_SECRET: preprod_secret_change_me
|
||||||
|
<<<<<<< HEAD
|
||||||
APP_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr
|
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
|
API_PUBLIC_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr
|
||||||
|
=======
|
||||||
|
APP_URL: http://192.168.1.23:5002
|
||||||
|
FRONTEND_URL: http://192.168.1.23:5174
|
||||||
|
MAIL_HOST: smtp.gmail.com
|
||||||
|
MAIL_PORT: 587
|
||||||
|
MAIL_USER: ""
|
||||||
|
MAIL_PASS: ""
|
||||||
|
MAIL_FROM: "The Tip Top Preprod <no-reply@tiptop.local>"
|
||||||
|
>>>>>>> github/Emmanuel
|
||||||
|
|
||||||
|
# Injectés par le pipeline, jamais commités. Vides, les routes de
|
||||||
|
# connexion externe répondent 503 sans bloquer le démarrage.
|
||||||
|
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}"
|
||||||
|
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
|
||||||
|
FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}"
|
||||||
|
FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}"
|
||||||
|
|
||||||
command: >
|
command: >
|
||||||
sh -c "
|
sh -c "
|
||||||
|
|||||||
@@ -36,8 +36,26 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
DATABASE_URL: postgresql://tiptop_prod:tiptop_prod_password@postgres:5432/the_tip_top_prod
|
DATABASE_URL: postgresql://tiptop_prod:tiptop_prod_password@postgres:5432/the_tip_top_prod
|
||||||
JWT_SECRET: prod_secret_change_me
|
JWT_SECRET: prod_secret_change_me
|
||||||
|
<<<<<<< HEAD
|
||||||
APP_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
|
APP_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
|
||||||
API_PUBLIC_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
|
API_PUBLIC_URL: https://api.dsp5-archi-o24a-4-5-g4.fr
|
||||||
|
=======
|
||||||
|
APP_URL: http://192.168.1.23:5003
|
||||||
|
FRONTEND_URL: http://192.168.1.23:5175
|
||||||
|
|
||||||
|
MAIL_HOST: smtp.gmail.com
|
||||||
|
MAIL_PORT: 587
|
||||||
|
MAIL_USER: ""
|
||||||
|
MAIL_PASS: ""
|
||||||
|
MAIL_FROM: "The Tip Top <no-reply@tiptop.local>"
|
||||||
|
>>>>>>> github/Emmanuel
|
||||||
|
|
||||||
|
# Injectés par le pipeline, jamais commités. Vides, les routes de
|
||||||
|
# connexion externe répondent 503 sans bloquer le démarrage.
|
||||||
|
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}"
|
||||||
|
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
|
||||||
|
FACEBOOK_APP_ID: "${FACEBOOK_APP_ID:-}"
|
||||||
|
FACEBOOK_APP_SECRET: "${FACEBOOK_APP_SECRET:-}"
|
||||||
|
|
||||||
command: >
|
command: >
|
||||||
sh -c "
|
sh -c "
|
||||||
|
|||||||
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 CookieBanner from './components/CookieBanner.jsx'
|
||||||
import Connexion from './pages/Connexion'
|
import Connexion from './pages/Connexion'
|
||||||
import Inscription from './pages/Inscription'
|
import Inscription from './pages/Inscription'
|
||||||
|
import AuthCallback from './pages/AuthCallback'
|
||||||
|
import DefinirMotDePasse from './pages/DefinirMotDePasse'
|
||||||
import Accueil from './pages/Accueil'
|
import Accueil from './pages/Accueil'
|
||||||
import ReglesJeu from './pages/ReglesJeu'
|
import ReglesJeu from './pages/ReglesJeu'
|
||||||
import MesGains from './pages/MesGains'
|
import MesGains from './pages/MesGains'
|
||||||
@@ -29,6 +31,8 @@ export default function App() {
|
|||||||
<Route path="/" element={<Navigate to="/accueil" replace />} />
|
<Route path="/" element={<Navigate to="/accueil" replace />} />
|
||||||
<Route path="/connexion" element={<Connexion />} />
|
<Route path="/connexion" element={<Connexion />} />
|
||||||
<Route path="/inscription" element={<Inscription />} />
|
<Route path="/inscription" element={<Inscription />} />
|
||||||
|
<Route path="/auth/callback" element={<AuthCallback />} />
|
||||||
|
<Route path="/definir-mot-de-passe/:token" element={<DefinirMotDePasse />} />
|
||||||
|
|
||||||
{/* Pages publiques */}
|
{/* Pages publiques */}
|
||||||
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />
|
<Route path="/accueil" element={<Layout><Accueil /></Layout>} />
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export default function Header() {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { user, logout } = useAuth()
|
const { user, logout } = useAuth()
|
||||||
const isLoggedIn = !!user
|
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 = () => {
|
const handleLogout = () => {
|
||||||
logout()
|
logout()
|
||||||
@@ -40,12 +40,12 @@ export default function Header() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Nav desktop */}
|
{/* 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) => (
|
{navLinks.map((link) => (
|
||||||
<Link
|
<Link
|
||||||
key={link.to}
|
key={link.to}
|
||||||
to={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]'
|
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">
|
<div className="w-8 h-8 rounded-full bg-[#006B6B] flex items-center justify-center text-white text-xs font-bold">
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
<span>{user.firstname}</span>
|
<span className="whitespace-nowrap">{user.firstname}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
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
|
Déconnexion
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<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
|
Connexion
|
||||||
</Link>
|
</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
|
Inscription
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default function UserNav({ active }) {
|
|||||||
key: 'code',
|
key: 'code',
|
||||||
to: '/saisir-code-unique',
|
to: '/saisir-code-unique',
|
||||||
label: 'Saisir un code',
|
label: 'Saisir un code',
|
||||||
|
shortLabel: 'Code',
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
<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" />
|
<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',
|
key: 'gains',
|
||||||
to: '/mes-gains',
|
to: '/mes-gains',
|
||||||
label: 'Mes gains',
|
label: 'Mes gains',
|
||||||
|
shortLabel: 'Mes gains',
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
<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" />
|
<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',
|
key: 'profile',
|
||||||
to: '/profile',
|
to: '/profile',
|
||||||
label: 'Profil',
|
label: 'Profil',
|
||||||
|
shortLabel: 'Profil',
|
||||||
icon: (
|
icon: (
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
<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" />
|
<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 ${
|
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
|
active === tab.key
|
||||||
? 'text-[#006B6B] border-[#006B6B]'
|
? 'text-[#006B6B] border-[#006B6B]'
|
||||||
: 'text-gray-400 border-transparent hover:text-gray-600'
|
: 'text-gray-500 border-transparent hover:text-gray-600'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.icon}
|
{tab.icon}
|
||||||
<span className="hidden sm:inline">{tab.label}</span>
|
<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>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { searchTicketApi, deliverClaimApi } from '../services/employee'
|
||||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
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">
|
<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-2xl sm:text-3xl font-bold text-[#006B6B]">{value ?? '—'}</p>
|
||||||
<p className="text-xs sm:text-sm text-gray-500 mt-1">{label}</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>
|
</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) {
|
function formatDate(dateStr) {
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||||
day: '2-digit', month: 'long', year: 'numeric',
|
day: '2-digit', month: 'long', year: 'numeric',
|
||||||
@@ -31,6 +57,12 @@ export default function Admin() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [showConfirm, setShowConfirm] = useState(false)
|
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
|
// Remise de lot
|
||||||
const [ticketCode, setTicketCode] = useState('')
|
const [ticketCode, setTicketCode] = useState('')
|
||||||
const [ticketResult, setTicketResult] = useState(null)
|
const [ticketResult, setTicketResult] = useState(null)
|
||||||
@@ -56,7 +88,26 @@ export default function Admin() {
|
|||||||
if (!handleAuthError(e)) setError(e.message)
|
if (!handleAuthError(e)) setError(e.message)
|
||||||
})
|
})
|
||||||
.finally(() => setLoading(false))
|
.finally(() => setLoading(false))
|
||||||
}, [handleAuthError])
|
}, [handleAuthError])
|
||||||
|
|
||||||
|
const handleSaveGameSettings = async (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setSettingsError('')
|
||||||
|
setSettingsOk('')
|
||||||
|
setSettingsSaving(true)
|
||||||
|
try {
|
||||||
|
// L'input datetime-local renvoie une heure locale sans fuseau : on la
|
||||||
|
// convertit en ISO pour que le serveur reçoive un instant sans ambiguïté.
|
||||||
|
const data = await setGameSettingsApi(new Date(startDateInput).toISOString())
|
||||||
|
setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev))
|
||||||
|
setSettingsOk(data.message)
|
||||||
|
setStartDateInput('')
|
||||||
|
} catch (err) {
|
||||||
|
if (!handleAuthError(err)) setSettingsError(err.message)
|
||||||
|
} finally {
|
||||||
|
setSettingsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleDraw = async () => {
|
const handleDraw = async () => {
|
||||||
setShowConfirm(false)
|
setShowConfirm(false)
|
||||||
@@ -94,7 +145,10 @@ export default function Admin() {
|
|||||||
const csv = [header, ...rows]
|
const csv = [header, ...rows]
|
||||||
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';'))
|
.map((row) => row.map((cell) => `"${String(cell).replace(/"/g, '""')}"`).join(';'))
|
||||||
.join('\n')
|
.join('\n')
|
||||||
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 url = URL.createObjectURL(blob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
@@ -145,7 +199,7 @@ export default function Admin() {
|
|||||||
|
|
||||||
if (loading) return (
|
if (loading) return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<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>
|
</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>
|
<h1 className="text-xl sm:text-2xl font-bold text-gray-800">Tableau de bord Admin</h1>
|
||||||
|
|
||||||
{/* Fenêtre du jeu-concours */}
|
{/* Fenêtre du jeu-concours */}
|
||||||
{stats?.gameWindow?.configured && (
|
{stats?.gameWindow && (
|
||||||
<div className={`rounded-xl shadow p-4 sm:p-6 ${stats.gameWindow.isOpen ? 'bg-white' : 'bg-red-50 border border-red-200'}`}>
|
<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 className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-gray-800">
|
<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>
|
|
||||||
<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)}
|
|
||||||
</p>
|
</p>
|
||||||
|
{stats.gameWindow.configured ? (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Début : {formatDate(stats.gameWindow.startDate)}
|
||||||
|
<br />
|
||||||
|
Fin des participations : {formatDate(stats.gameWindow.participationDeadline)}
|
||||||
|
<br />
|
||||||
|
Fin des retraits de lots : {formatDate(stats.gameWindow.claimDeadline)}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-600 mt-1">
|
||||||
|
Choisissez une date de début pour ouvrir le jeu-concours.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</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'}`}>
|
<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'}`}>
|
||||||
{stats.gameWindow.isOpen ? 'Ouvert' : 'Fermé'}
|
{PHASE_BADGES[stats.gameWindow.phase]?.label ?? 'État inconnu'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -302,7 +396,7 @@ export default function Admin() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{emailingContacts.length === 0 && (
|
{emailingContacts.length === 0 && (
|
||||||
<tr>
|
<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
|
Aucun contact n'a accepté l'emailing pour le moment
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -337,7 +431,7 @@ export default function Admin() {
|
|||||||
onChange={(e) => setTicketCode(e.target.value.toUpperCase())}
|
onChange={(e) => setTicketCode(e.target.value.toUpperCase())}
|
||||||
placeholder="Code ticket (10 caractères)"
|
placeholder="Code ticket (10 caractères)"
|
||||||
maxLength={10}
|
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
|
<button
|
||||||
type="submit"
|
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 className="px-4 py-3 flex items-start justify-between gap-3 border-b border-gray-100">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold text-gray-900 text-sm">{ticketResult.lot.name}</p>
|
<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>}
|
{ticketResult.lot.value && <p className="text-[#006B6B] text-xs font-semibold mt-1">{ticketResult.lot.value} €</p>}
|
||||||
</div>
|
</div>
|
||||||
<span className={`flex-shrink-0 text-xs font-semibold px-2.5 py-1 rounded-full ${
|
<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>
|
||||||
</div>
|
</div>
|
||||||
{winner?.winner?.createdAt && (
|
{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)}
|
Tirage effectué le {formatDate(winner.winner.createdAt)}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Pas encore de tirage */
|
/* Pas encore de tirage */
|
||||||
@@ -474,7 +575,7 @@ export default function Admin() {
|
|||||||
{/* Liste participants */}
|
{/* Liste participants */}
|
||||||
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
<div className="bg-white rounded-xl shadow p-4 sm:p-6">
|
||||||
<h2 className="text-base sm:text-lg font-bold text-gray-800 mb-4">
|
<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>
|
</h2>
|
||||||
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||||
<table className="w-full text-sm min-w-[400px]">
|
<table className="w-full text-sm min-w-[400px]">
|
||||||
@@ -488,7 +589,7 @@ export default function Admin() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{users.length === 0 && (
|
{users.length === 0 && (
|
||||||
<tr>
|
<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
|
Aucun participant pour le moment
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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,12 @@ export default function Connexion() {
|
|||||||
|
|
||||||
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||||
|
|
||||||
|
// Navigation pleine page (et non fetch) : le flux OAuth passe par des
|
||||||
|
// redirections vers Google puis retour sur /auth/callback.
|
||||||
|
const handleGoogleLogin = () => {
|
||||||
|
window.location.href = '/api/auth/google'
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setError('')
|
setError('')
|
||||||
@@ -66,7 +72,7 @@ export default function Connexion() {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
placeholder="Votre email"
|
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>
|
||||||
|
|
||||||
@@ -83,12 +89,12 @@ export default function Connexion() {
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
placeholder="Votre mot de passe"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
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"
|
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">
|
<div className="w-6 h-6 rounded-full border-2 border-gray-400 flex items-center justify-center">
|
||||||
@@ -99,7 +105,7 @@ export default function Connexion() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right mt-1">
|
<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é ?
|
Mot de passe oublié ?
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -123,20 +129,27 @@ export default function Connexion() {
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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">
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
||||||
G
|
<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>
|
||||||
</div>
|
<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
|
Google
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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">
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
f
|
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
||||||
</div>
|
<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
|
Facebook
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,152 +3,43 @@ import imgContact from '../assets/img/img-contact.jpg'
|
|||||||
export default function Contact() {
|
export default function Contact() {
|
||||||
return (
|
return (
|
||||||
<main className="bg-[#F0F0F0] px-4 sm:px-6 py-10 sm:py-16">
|
<main className="bg-[#F0F0F0] px-4 sm:px-6 py-10 sm:py-16">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto grid md:grid-cols-2 gap-6 sm:gap-8">
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-6 sm:gap-8 mb-8">
|
{/* Informations */}
|
||||||
|
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
||||||
{/* Informations */}
|
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
||||||
<section className="bg-white rounded-2xl shadow p-5 sm:p-8 md:p-10">
|
Contact
|
||||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#006B6B] mb-4 sm:mb-6">
|
</h1>
|
||||||
Contact
|
<p className="text-gray-600 leading-relaxed mb-6 sm:mb-8 text-sm sm:text-base">
|
||||||
</h1>
|
Une question concernant le jeu-concours, votre participation ou votre lot ?
|
||||||
|
Notre équipe ThéTipTop est à votre écoute.
|
||||||
<p className="text-gray-600 leading-relaxed mb-6 sm:mb-8 text-sm sm:text-base">
|
</p>
|
||||||
Une question concernant le jeu-concours, votre participation ou votre lot ?
|
<div className="space-y-5 text-gray-700">
|
||||||
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>
|
|
||||||
</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>
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Image */}
|
|
||||||
<section className="rounded-2xl shadow overflow-hidden min-h-80">
|
|
||||||
<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>
|
<div>
|
||||||
<label
|
<h2 className="font-semibold text-[#006B6B] mb-1">Adresse</h2>
|
||||||
htmlFor="contact-name"
|
<p className="text-sm sm:text-base">ThéTipTop<br />18 rue Léon Frot<br />75011 Paris, France</p>
|
||||||
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<h2 className="font-semibold text-[#006B6B] mb-1">Email</h2>
|
||||||
htmlFor="contact-email"
|
<a href="mailto:contact@thetiptop.fr" className="text-sm sm:text-base hover:text-[#006B6B]">contact@thetiptop.fr</a>
|
||||||
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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<h2 className="font-semibold text-[#006B6B] mb-1">Téléphone</h2>
|
||||||
htmlFor="contact-subject"
|
<a href="tel:+33184802034" className="text-sm sm:text-base hover:text-[#006B6B]">01 84 80 20 34</a>
|
||||||
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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div>
|
{/* Image */}
|
||||||
<label
|
<section className="rounded-2xl shadow overflow-hidden h-64 sm:h-80 md:h-[468px] md:self-start">
|
||||||
htmlFor="contact-message"
|
<img
|
||||||
className="block text-gray-700 mb-1.5 text-sm sm:text-base"
|
src={imgContact}
|
||||||
>
|
alt="Une personne savourant un thé"
|
||||||
Message
|
className="w-full h-full object-cover"
|
||||||
</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>
|
</section>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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
|
required
|
||||||
placeholder="Code ticket (10 caractères)"
|
placeholder="Code ticket (10 caractères)"
|
||||||
maxLength={10}
|
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -142,7 +142,7 @@ export default function Employe() {
|
|||||||
|
|
||||||
{/* Infos client */}
|
{/* Infos client */}
|
||||||
<div className="px-5 py-4 space-y-2 border-b border-gray-100">
|
<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="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">
|
<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()}
|
{(result.client.firstname?.[0] ?? '?').toUpperCase()}{(result.client.lastname?.[0] ?? '?').toUpperCase()}
|
||||||
@@ -158,7 +158,7 @@ export default function Employe() {
|
|||||||
|
|
||||||
{/* Infos ticket */}
|
{/* Infos ticket */}
|
||||||
<div className="px-5 py-4 space-y-1 border-b border-gray-100">
|
<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">
|
<p className="text-sm text-gray-700">
|
||||||
<span className="font-medium">Code :</span>{' '}
|
<span className="font-medium">Code :</span>{' '}
|
||||||
<span className="font-mono">{result.ticket.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 { registerApi, verifyCodeApi } from '../services/auth'
|
||||||
import { useAuth } from '../context/AuthContext'
|
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() {
|
export default function Inscription() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
@@ -10,8 +17,18 @@ export default function Inscription() {
|
|||||||
// Étape : 'form' | 'verify'
|
// Étape : 'form' | 'verify'
|
||||||
const [step, setStep] = useState('form')
|
const [step, setStep] = useState('form')
|
||||||
const [email, setEmail] = useState('')
|
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)
|
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 [showPassword, setShowPassword] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -24,6 +41,11 @@ export default function Inscription() {
|
|||||||
|
|
||||||
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
const handleChange = (e) => setForm({ ...form, [e.target.name]: e.target.value })
|
||||||
|
|
||||||
|
// Même route que la connexion : Google crée le compte à la première venue.
|
||||||
|
const handleGoogleSignup = () => {
|
||||||
|
window.location.href = '/api/auth/google'
|
||||||
|
}
|
||||||
|
|
||||||
// Étape 1 — Inscription
|
// Étape 1 — Inscription
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -35,6 +57,9 @@ export default function Inscription() {
|
|||||||
firstname: form.firstName,
|
firstname: form.firstName,
|
||||||
lastname: form.lastName,
|
lastname: form.lastName,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
|
birthdate: form.birthdate,
|
||||||
|
gender: form.gender,
|
||||||
|
marketingOptIn,
|
||||||
})
|
})
|
||||||
setEmail(form.email)
|
setEmail(form.email)
|
||||||
setStep('verify')
|
setStep('verify')
|
||||||
@@ -129,7 +154,7 @@ export default function Inscription() {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</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.{' '}
|
Le code expire dans 15 minutes.{' '}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -161,28 +186,45 @@ export default function Inscription() {
|
|||||||
<div>
|
<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>
|
<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"
|
<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>
|
||||||
|
|
||||||
<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>
|
<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"
|
<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>
|
||||||
|
|
||||||
<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>
|
<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"
|
<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>
|
||||||
|
|
||||||
<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>
|
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input type={showPassword ? 'text' : 'password'} id="reg-password" name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
|
<input type={showPassword ? 'text' : 'password'} id="reg-password" name="password" value={form.password} onChange={handleChange} required placeholder="Votre mot de passe"
|
||||||
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)}
|
<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">
|
<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" />}
|
{showPassword && <div className="w-1.5 h-1.5 rounded-full bg-gray-400" />}
|
||||||
</div>
|
</div>
|
||||||
@@ -192,7 +234,7 @@ export default function Inscription() {
|
|||||||
|
|
||||||
<label className="flex items-start gap-3 cursor-pointer">
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
<input type="checkbox" checked={acceptedTerms} onChange={(e) => setAcceptedTerms(e.target.checked)} required
|
<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">
|
<span className="text-xs text-gray-500 leading-relaxed">
|
||||||
J'accepte les{' '}
|
J'accepte les{' '}
|
||||||
<Link to="/cgu" className="text-[#006B6B] hover:underline font-medium">conditions d'utilisation</Link>{' '}
|
<Link to="/cgu" className="text-[#006B6B] hover:underline font-medium">conditions d'utilisation</Link>{' '}
|
||||||
@@ -201,6 +243,18 @@ export default function Inscription() {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</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}
|
<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">
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors">
|
||||||
{loading ? 'Inscription…' : "S'inscrire"}
|
{loading ? 'Inscription…' : "S'inscrire"}
|
||||||
@@ -214,12 +268,20 @@ export default function Inscription() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<button type="button" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors">
|
<button type="button" onClick={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">
|
||||||
<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
|
Google
|
||||||
</button>
|
</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">
|
<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">
|
||||||
<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
|
Facebook
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export default function MesGains() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{error && (
|
||||||
<div className="px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
<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 && (
|
{!loading && !error && gains.length === 0 && (
|
||||||
<div className="text-center mt-12">
|
<div className="text-center mt-12">
|
||||||
<p className="text-gray-400 text-sm">Aucun gain pour le moment.</p>
|
<p className="text-gray-500 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-xs mt-1">Saisissez un code pour participer !</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ export default function MesGains() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-gray-500 text-xs mt-1 leading-relaxed">{gain.description}</p>
|
<p className="text-gray-500 text-xs mt-1 leading-relaxed">{gain.description}</p>
|
||||||
{gain.value && (
|
{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">
|
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||||
<p className="text-[#006B6B] text-xs">Gagné le {formatDate(gain.wonAt)}</p>
|
<p className="text-[#006B6B] text-xs">Gagné le {formatDate(gain.wonAt)}</p>
|
||||||
@@ -128,7 +128,7 @@ export default function MesGains() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-400 text-[10px] mt-1">
|
<p className="text-gray-500 text-[10px] mt-1">
|
||||||
Code utilisé :{' '}
|
Code utilisé :{' '}
|
||||||
<span className="font-mono tracking-widest text-gray-600">{gain.ticketCode}</span>
|
<span className="font-mono tracking-widest text-gray-600">{gain.ticketCode}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function NotFound() {
|
|||||||
Page introuvable
|
Page introuvable
|
||||||
</h1>
|
</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.
|
La page que vous recherchez n'existe pas ou a été déplacée.
|
||||||
</p>
|
</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="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">
|
<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" />
|
<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>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -22,7 +22,7 @@ export default function Perdu() {
|
|||||||
{message || 'Ce code est invalide ou a déjà été utilisé.'}
|
{message || 'Ce code est invalide ou a déjà été utilisé.'}
|
||||||
</p>
|
</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.
|
Vérifiez le code présent sur votre ticket et réessayez.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import UserNav from '../components/UserNav'
|
import UserNav from '../components/UserNav'
|
||||||
import { changePasswordApi} from '../services/auth'
|
import { changePasswordApi, meApi, forgotPasswordApi } from '../services/auth'
|
||||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
||||||
|
|
||||||
export default function Profile() {
|
export default function Profile() {
|
||||||
@@ -14,6 +14,35 @@ export default function Profile() {
|
|||||||
const [pwError, setPwError] = useState('')
|
const [pwError, setPwError] = useState('')
|
||||||
const [pwSuccess, setPwSuccess] = useState(false)
|
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 handlePwChange = (e) => setPwForm({ ...pwForm, [e.target.name]: e.target.value })
|
||||||
|
|
||||||
const handlePwSubmit = async (e) => {
|
const handlePwSubmit = async (e) => {
|
||||||
@@ -65,19 +94,19 @@ export default function Profile() {
|
|||||||
{/* Infos du compte (lecture seule) */}
|
{/* Infos du compte (lecture seule) */}
|
||||||
<div className="space-y-4 mb-8">
|
<div className="space-y-4 mb-8">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-400 uppercase tracking-wider mb-1">Adresse email</label>
|
<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-400 bg-gray-50 cursor-not-allowed select-none">
|
<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 || '—'}
|
{user.email || '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div className="w-full border border-gray-100 rounded-lg px-4 py-3 text-sm text-gray-500 bg-gray-50">
|
||||||
{user.lastname || '—'}
|
{user.lastname || '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div className="w-full border border-gray-100 rounded-lg px-4 py-3 text-sm text-gray-500 bg-gray-50">
|
||||||
{user.firstname || '—'}
|
{user.firstname || '—'}
|
||||||
</div>
|
</div>
|
||||||
@@ -89,9 +118,37 @@ export default function Profile() {
|
|||||||
|
|
||||||
{/* Section changement de mot de passe */}
|
{/* Section changement de mot de passe */}
|
||||||
<div>
|
<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">
|
<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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
@@ -100,12 +157,13 @@ export default function Profile() {
|
|||||||
</div>
|
</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">
|
<div className="mb-4 px-4 py-3 rounded-lg bg-red-50 border border-red-200 text-red-600 text-sm">
|
||||||
{pwError}
|
{pwError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{hasPassword !== false && (
|
||||||
<form onSubmit={handlePwSubmit} className="space-y-4">
|
<form onSubmit={handlePwSubmit} className="space-y-4">
|
||||||
{[
|
{[
|
||||||
{ name: 'currentPassword', label: 'Mot de passe actuel', key: 'current' },
|
{ name: 'currentPassword', label: 'Mot de passe actuel', key: 'current' },
|
||||||
@@ -123,12 +181,12 @@ export default function Profile() {
|
|||||||
onChange={handlePwChange}
|
onChange={handlePwChange}
|
||||||
required
|
required
|
||||||
placeholder="••••••••"
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setShowPw((s) => ({ ...s, [key]: !s[key] }))}
|
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"
|
aria-label="Afficher/masquer"
|
||||||
>
|
>
|
||||||
<EyeIcon visible={showPw[key]} />
|
<EyeIcon visible={showPw[key]} />
|
||||||
@@ -145,6 +203,7 @@ export default function Profile() {
|
|||||||
{pwLoading ? 'Modification…' : 'Modifier le mot de passe'}
|
{pwLoading ? 'Modification…' : 'Modifier le mot de passe'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export default function SaisirCodeUnique() {
|
|||||||
placeholder="Votre code"
|
placeholder="Votre code"
|
||||||
maxLength={10}
|
maxLength={10}
|
||||||
required
|
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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -144,28 +144,28 @@ export default function SaisirCodeUnique() {
|
|||||||
{/* Valeur */}
|
{/* Valeur */}
|
||||||
{gain.value && (
|
{gain.value && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-gray-100">
|
<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>
|
<span className="text-sm font-semibold text-[#006B6B]">{gain.value} €</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Code utilisé */}
|
{/* Code utilisé */}
|
||||||
<div className="flex items-center justify-between py-2 border-b border-gray-100">
|
<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>
|
<span className="font-mono text-sm tracking-widest text-gray-700">{code}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Date & heure */}
|
{/* Date & heure */}
|
||||||
{wonAt && (
|
{wonAt && (
|
||||||
<div className="flex items-center justify-between py-2 border-b border-gray-100">
|
<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>
|
<span className="text-sm text-gray-700">{formatDateTime(wonAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Statut */}
|
{/* Statut */}
|
||||||
<div className="flex items-center justify-between py-2">
|
<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">
|
<span className="text-xs font-semibold px-2.5 py-1 rounded-full bg-yellow-100 text-yellow-700">
|
||||||
En attente de remise
|
En attente de remise
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function Sitemap() {
|
|||||||
<p className="text-sm font-medium text-gray-800 group-hover:text-[#006B6B] transition-colors">
|
<p className="text-sm font-medium text-gray-800 group-hover:text-[#006B6B] transition-colors">
|
||||||
{link.label}
|
{link.label}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -49,3 +49,13 @@ export async function getWinnerApi() {
|
|||||||
if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status)
|
if (!res.ok) throw apiError(data.message || 'Erreur gagnant.', res.status)
|
||||||
return data
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,11 +18,29 @@ export async function loginApi({ email, password }) {
|
|||||||
return data
|
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`, {
|
const res = await fetch(`${BASE}/register`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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()
|
const data = await res.json()
|
||||||
if (!res.ok) throw apiError(data.message || "Erreur lors de l'inscription.", res.status)
|
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
|
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() {
|
export async function deleteAccountApi() {
|
||||||
const token = localStorage.getItem('token')
|
const token = localStorage.getItem('token')
|
||||||
const res = await fetch(`${BASE}/account`, {
|
const res = await fetch(`${BASE}/account`, {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ async function remplirEtSoumettre() {
|
|||||||
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
|
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
|
||||||
await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean')
|
await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean')
|
||||||
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
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 }))
|
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 })
|
const bouton = screen.getByRole('button', { name: /s'inscrire/i })
|
||||||
expect(bouton).toBeDisabled()
|
expect(bouton).toBeDisabled()
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole('checkbox'))
|
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
||||||
expect(bouton).toBeEnabled()
|
expect(bouton).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -64,11 +64,64 @@ describe('Inscription', () => {
|
|||||||
firstname: 'Jean',
|
firstname: 'Jean',
|
||||||
lastname: 'Dupont',
|
lastname: 'Dupont',
|
||||||
password: 'MotDePasse123!',
|
password: 'MotDePasse123!',
|
||||||
|
birthdate: '',
|
||||||
|
gender: '',
|
||||||
|
marketingOptIn: false,
|
||||||
})
|
})
|
||||||
expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument()
|
||||||
expect(screen.getByText('jean@example.com')).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 () => {
|
it("affiche l'erreur de l'API si l'inscription échoue", async () => {
|
||||||
registerApi.mockRejectedValue(new Error('Cet email est déjà utilisé.'))
|
registerApi.mockRejectedValue(new Error('Cet email est déjà utilisé.'))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user