diff --git a/Jenkinsfile b/Jenkinsfile index b7b2a71..6831941 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -163,6 +163,7 @@ pipeline { npm ci --prefer-offline --no-audit --no-fund && npx prisma generate && npx prisma migrate deploy && + npx prisma db seed && npm test " ''' diff --git a/backend/.env.example b/backend/.env.example index 08da568..47c457f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -5,8 +5,17 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/the_tip_top_db" JWT_SECRET="your_secret_key" APP_URL="http://localhost:5000" +FRONTEND_URL="http://localhost:5173" MAIL_HOST="smtp.gmail.com" MAIL_PORT="587" -MAIL_USER="your_email@gmail.com" -MAIL_PASS="your_app_password" -MAIL_FROM="Thé Tip Top " +MAIL_USER=abayomiandil62@gmail.com +MAIL_PASS=dumqamoupdrliygf +MAIL_FROM="Thé Tip Top " + +# 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="" diff --git a/backend/.gitignore b/backend/.gitignore index 24361b6..ae19cfb 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -2,4 +2,5 @@ node_modules/ .env .prisma coverage/ -dist/ \ No newline at end of file +dist/ +test-results/ diff --git a/backend/package-lock.json b/backend/package-lock.json index e94be78..6f13beb 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -13,8 +13,12 @@ "bcrypt": "^6.0.0", "cors": "^2.8.6", "express": "^5.2.1", + "express-rate-limit": "^8.6.1", + "helmet": "^8.3.0", "jsonwebtoken": "^9.0.3", "nodemailer": "^9.0.3", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "prisma": "^6.19.3", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", @@ -2197,6 +2201,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.40", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", @@ -3210,6 +3223,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/express-rate-limit": { + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.2.tgz", + "integrity": "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -3658,6 +3690,18 @@ "node": ">= 0.4" } }, + "node_modules/helmet": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz", + "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/EvanHahn" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -3766,6 +3810,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -5487,9 +5540,9 @@ } }, "node_modules/nodemailer": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", - "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz", + "integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -5608,6 +5661,12 @@ "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", "license": "MIT" }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==", + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5768,6 +5827,64 @@ "node": ">= 0.8" } }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "license": "MIT", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "license": "MIT", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5829,6 +5946,11 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, "node_modules/perfect-debounce": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", @@ -6774,6 +6896,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -6866,6 +6994,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/uuid": { "version": "14.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 43152fa..a637c62 100644 --- a/backend/package.json +++ b/backend/package.json @@ -20,8 +20,12 @@ "bcrypt": "^6.0.0", "cors": "^2.8.6", "express": "^5.2.1", + "express-rate-limit": "^8.6.1", + "helmet": "^8.3.0", "jsonwebtoken": "^9.0.3", "nodemailer": "^9.0.3", + "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "prisma": "^6.19.3", "swagger-jsdoc": "^6.3.0", "swagger-ui-express": "^5.0.1", diff --git a/backend/prisma/migrations/20260817000000_add_oauth_providers/migration.sql b/backend/prisma/migrations/20260817000000_add_oauth_providers/migration.sql new file mode 100644 index 0000000..1aa9d93 --- /dev/null +++ b/backend/prisma/migrations/20260817000000_add_oauth_providers/migration.sql @@ -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"); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7ab6470..89737cd 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -24,12 +24,20 @@ enum ClaimStatus { CANCELLED } +enum AuthProvider { + LOCAL + GOOGLE + FACEBOOK +} + model User { id Int @id @default(autoincrement()) firstname String lastname String email String @unique - password String + // Nul pour les comptes créés via un provider externe (Google, Facebook) : + // ces utilisateurs n'ont jamais choisi de mot de passe chez nous. + password String? role Role @default(CLIENT) birthdate DateTime? gender String? @@ -43,10 +51,16 @@ model User { resetPasswordToken String? resetPasswordExpires DateTime? + provider AuthProvider @default(LOCAL) + providerId String? + avatarUrl String? + tickets Ticket[] participations Participation[] grandPrizeWinner GrandPrizeWinner? claimsValidated Claim[] @relation("EmployeeClaims") + + @@unique([provider, providerId]) } model Lot { diff --git a/backend/src/app.js b/backend/src/app.js index 0687edd..d0c5d3d 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -1,5 +1,7 @@ const express = require("express"); const cors = require("cors"); +const helmet = require("helmet"); +const rateLimit = require("express-rate-limit"); require("dotenv").config(); const authRoutes = require("./routes/auth.routes"); @@ -9,11 +11,37 @@ const adminRoutes = require("./routes/admin.routes"); const swaggerUi = require("swagger-ui-express"); const swaggerSpec = require("./docs/swagger"); +const { passport } = require("./config/passport"); const app = express(); -app.use(cors()); +const allowedOrigins = (process.env.FRONTEND_URL || "http://localhost:5173").split(","); + +// La CSP par défaut de helmet bloque les scripts inline de Swagger UI : +// on l'applique partout sauf sur /api-docs, où une CSP dédiée (désactivée) +// est posée juste avant de monter Swagger, plus bas. +app.use((req, res, next) => { + if (req.path.startsWith("/api-docs")) return next(); + return helmet()(req, res, next); +}); +app.use(cors({ origin: allowedOrigins })); app.use(express.json()); +// OAuth stateless : initialize() suffit, aucune session n'est ouverte. +app.use(passport.initialize()); + +// Anti-bruteforce sur les routes d'authentification uniquement : 20 +// tentatives par IP toutes les 15 minutes, sans limiter le reste de l'API +// (participation au jeu, consultation des gains, etc.). +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 20, + standardHeaders: true, + legacyHeaders: false, + message: { message: "Trop de tentatives, réessayez dans quelques minutes." }, +}); +app.use("/api/auth/login", authLimiter); +app.use("/api/auth/register", authLimiter); +app.use("/api/auth/forgot-password", authLimiter); // routes app.use("/api/auth", authRoutes); @@ -22,7 +50,7 @@ app.use("/api/employee", employeeRoutes); app.use("/api/admin", adminRoutes); // swagger -app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerSpec)); +app.use("/api-docs", helmet({ contentSecurityPolicy: false }), swaggerUi.serve, swaggerUi.setup(swaggerSpec)); // test route app.get("/", (req, res) => { diff --git a/backend/src/config/passport.js b/backend/src/config/passport.js new file mode 100644 index 0000000..1e9a434 --- /dev/null +++ b/backend/src/config/passport.js @@ -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: ` +
+

Bienvenue ${user.firstname} !

+

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.

+

+ + Définir mon mot de passe + +

+

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.

+
+ `, + }); +} + +// 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 }; diff --git a/backend/src/controllers/admin.controller.js b/backend/src/controllers/admin.controller.js index 8651bf0..ecca247 100644 --- a/backend/src/controllers/admin.controller.js +++ b/backend/src/controllers/admin.controller.js @@ -1,6 +1,8 @@ const bcrypt = require("bcrypt"); +const crypto = require("crypto"); const prisma = require("../config/prisma"); -const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow"); +const transporter = require("../config/mailer"); +const { getGameWindow } = require("../utils/gameWindow"); const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"]; @@ -33,7 +35,6 @@ function computeAgeStats(users) { const getStats = async (req, res) => { try { - await expireOverdueClaims(); const gameWindow = await getGameWindow(); const [ @@ -79,9 +80,11 @@ const getStats = async (req, res) => { ticketsUsed, ticketsRemaining: ticketsTotal - ticketsUsed, participations, - claimsPending, + // L'expiration n'est pas écrite en base : une fois la fenêtre de retrait + // fermée, les PENDING restants comptent comme annulés. + claimsPending: gameWindow.isOpen ? claimsPending : 0, claimsDelivered, - claimsCancelled, + claimsCancelled: gameWindow.isOpen ? claimsCancelled : claimsCancelled + claimsPending, users, gameWindow, ageStats: computeAgeStats(clientsWithBirthdate), @@ -214,6 +217,31 @@ const createEmployee = async (req, res) => { const drawWinner = async (req, res) => { try { + // Le grand gagnant est tiré parmi tous les participants : le tirage n'a de + // sens qu'une fois la période de participation close. Avant l'ouverture, + // il n'existe aucun participant à départager. + const gameWindow = await getGameWindow(); + + if (!gameWindow.configured) { + return res.status(409).json({ + message: "Le jeu-concours n'a pas encore été lancé : aucun tirage au sort n'est possible.", + }); + } + + if (!gameWindow.hasStarted) { + return res.status(409).json({ + message: `Le jeu-concours n'a pas encore commencé. Il ouvre le ${gameWindow.startDate.toLocaleDateString("fr-FR")}.`, + startDate: gameWindow.startDate, + }); + } + + if (gameWindow.isParticipationOpen) { + return res.status(409).json({ + message: `Le tirage au sort ne peut avoir lieu qu'après la fin des participations, le ${gameWindow.participationDeadline.toLocaleDateString("fr-FR")}.`, + participationDeadline: gameWindow.participationDeadline, + }); + } + const existingWinner = await prisma.grandPrizeWinner.findFirst({ include: { user: { @@ -234,6 +262,10 @@ const drawWinner = async (req, res) => { }); } + // Un utilisateur = une entrée dans le pool, quel que soit son nombre de + // participations. On sélectionne des CLIENT distincts (pas des + // participations), donc avoir utilisé 10 tickets ou 1 seul ne change + // strictement rien aux chances de gagner le gros lot. const participants = await prisma.user.findMany({ where: { role: "CLIENT", @@ -255,7 +287,11 @@ const drawWinner = async (req, res) => { }); } - const randomIndex = Math.floor(Math.random() * participants.length); + // Tirage purement aléatoire et équiprobable parmi les participants + // distincts. crypto.randomInt (générateur cryptographiquement sûr, + // sans biais) plutôt que Math.random(), pour un tirage défendable + // devant l'huissier qui le supervise. + const randomIndex = crypto.randomInt(participants.length); const winner = participants[randomIndex]; const savedWinner = await prisma.grandPrizeWinner.create({ @@ -274,9 +310,41 @@ const drawWinner = async (req, res) => { }, }); + // Le tirage est un acte irréversible réalisé sous contrôle d'huissier : + // un email qui ne part pas ne doit surtout pas l'invalider. On signale + // simplement l'échec pour que l'administrateur prévienne autrement. + let emailSent = true; + + try { + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: savedWinner.user.email, + subject: "Vous avez gagné le gros lot Thé Tip Top !", + html: ` +
+

Félicitations ${savedWinner.user.firstname} !

+

Le tirage au sort du jeu-concours « 100% Gagnant » vient d'avoir lieu, + et c'est vous qui remportez le gros lot :

+
+ ${savedWinner.prize} +
+

Notre équipe vous recontactera très prochainement pour convenir des + modalités de remise de votre lot.

+

Merci de votre participation et à bientôt chez Thé Tip Top.

+
+ `, + }); + } catch (mailError) { + emailSent = false; + console.error("Erreur envoi email au grand gagnant:", mailError); + } + return res.status(201).json({ - message: "Tirage au sort effectué avec succès.", + message: emailSent + ? "Tirage au sort effectué avec succès. Le gagnant a été prévenu par email." + : "Tirage au sort effectué avec succès, mais l'email n'a pas pu être envoyé au gagnant. Merci de le contacter directement.", totalEligibleParticipants: participants.length, + emailSent, winner: savedWinner, }); } catch (error) { @@ -287,6 +355,52 @@ const drawWinner = async (req, res) => { } }; +// Lancement du jeu-concours : fixe (ou corrige) la date de début, dont +// découlent les deux échéances. Une seule ligne de configuration existe, on +// la met donc à jour si elle est déjà présente. +const setGameSettings = async (req, res) => { + try { + const { startDate } = req.body; + const parsedStartDate = new Date(startDate); + + const existing = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + + if (existing) { + await prisma.gameSettings.update({ + where: { id: existing.id }, + data: { startDate: parsedStartDate }, + }); + } else { + await prisma.gameSettings.create({ data: { startDate: parsedStartDate } }); + } + + const gameWindow = await getGameWindow(); + + return res.json({ + message: existing + ? "Dates du jeu-concours mises à jour." + : "Jeu-concours lancé avec succès.", + gameWindow, + }); + } catch (error) { + console.error("Erreur configuration du jeu:", error); + return res.status(500).json({ + message: "Erreur serveur lors de la configuration du jeu-concours.", + }); + } +}; + +const getGameSettings = async (req, res) => { + try { + return res.json({ gameWindow: await getGameWindow() }); + } catch (error) { + console.error("Erreur lecture configuration du jeu:", error); + return res.status(500).json({ + message: "Erreur serveur lors de la lecture de la configuration.", + }); + } +}; + const getGrandPrizeWinner = async (req, res) => { try { const winner = await prisma.grandPrizeWinner.findFirst({ @@ -302,14 +416,8 @@ const getGrandPrizeWinner = async (req, res) => { }, }); - if (!winner) { - return res.status(404).json({ - message: "Aucun gagnant du gros lot n'a encore été tiré.", - }); - } - return res.json({ - winner, + winner: winner ?? null, }); } catch (error) { console.error("Erreur récupération gagnant:", error); @@ -326,4 +434,6 @@ module.exports = { createEmployee, drawWinner, getGrandPrizeWinner, + setGameSettings, + getGameSettings, }; \ No newline at end of file diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index 4810fd6..cef2552 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -62,11 +62,17 @@ const register = async (req, res) => { }, }); - await transporter.sendMail({ - from: process.env.MAIL_FROM, - to: email, - subject: "Votre code de validation Thé Tip Top", - html: ` + // Le code de validation est le seul moyen d'activer le compte. Si l'envoi + // échoue, laisser l'utilisateur en base le bloquerait définitivement : il + // ne pourrait ni se connecter (email non vérifié) ni se réinscrire (email + // déjà pris). On annule donc la création pour que la tentative reste + // rejouable. + try { + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: email, + subject: "Votre code de validation Thé Tip Top", + html: `

Bienvenue ${firstname} !

Merci pour votre inscription sur Thé Tip Top.

@@ -77,7 +83,21 @@ const register = async (req, res) => {

Ce code expire dans 15 minutes.

`, - }); + }); + } catch (mailError) { + console.error("Erreur envoi du code de validation:", mailError); + + try { + await prisma.user.delete({ where: { id: user.id } }); + } catch (rollbackError) { + console.error("Echec de l'annulation de l'inscription:", rollbackError); + } + + return res.status(502).json({ + message: + "Votre compte n'a pas pu être créé : l'email de validation n'a pas pu être envoyé. Merci de réessayer dans quelques instants.", + }); + } return res.status(201).json({ message: "Inscription réussie. Un code de validation a été envoyé par email.", @@ -156,6 +176,14 @@ const login = async (req, res) => { }); } + // Compte créé via un provider externe : aucun mot de passe à comparer. + if (!user.password) { + const providerLabel = user.provider === "FACEBOOK" ? "Facebook" : "Google"; + return res.status(401).json({ + message: `Ce compte utilise la connexion ${providerLabel}. Utilisez le bouton « ${providerLabel} » pour vous connecter.`, + }); + } + const isPasswordValid = await bcrypt.compare(password, user.password); if (!isPasswordValid) { @@ -224,7 +252,10 @@ const forgotPassword = async (req, res) => { }, }); - const resetUrl = `${process.env.APP_URL}/api/auth/reset-password/${resetPasswordToken}`; + // Le lien doit ouvrir le formulaire du front, pas la route POST de l'API : + // un clic depuis un email est une requête GET, qui tombait en 404. + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + const resetUrl = `${frontendUrl}/definir-mot-de-passe/${resetPasswordToken}`; await transporter.sendMail({ from: process.env.MAIL_FROM, @@ -255,7 +286,7 @@ const forgotPassword = async (req, res) => { const resetPassword = async (req, res) => { try { const { token } = req.params; - const { password } = req.body; + const { password, confirmPassword } = req.body; if (!password || password.length < 8) { return res.status(400).json({ @@ -263,6 +294,14 @@ const resetPassword = async (req, res) => { }); } + // La confirmation est vérifiée côté serveur aussi : le contrôle du + // navigateur ne protège pas un appel direct à l'API. + if (confirmPassword !== undefined && password !== confirmPassword) { + return res.status(400).json({ + message: "Les deux mots de passe ne correspondent pas.", + }); + } + const user = await prisma.user.findFirst({ where: { resetPasswordToken: token, @@ -354,6 +393,50 @@ const verifyCode = async (req, res) => { } }; +// Fin du parcours OAuth : passport a posé l'utilisateur sur req.user, on émet +// le même JWT que la connexion classique et on renvoie le navigateur vers le +// front, qui récupère le token dans l'URL. +const oauthCallback = (req, res) => { + const frontendUrl = process.env.FRONTEND_URL || "http://localhost:5173"; + + try { + const user = req.user; + + if (!user) { + return res.redirect( + `${frontendUrl}/auth/callback?error=${encodeURIComponent( + "Connexion externe refusée." + )}` + ); + } + + const token = jwt.sign( + { id: user.id, role: user.role }, + process.env.JWT_SECRET, + { expiresIn: "24h" } + ); + + const payload = Buffer.from( + JSON.stringify({ + id: user.id, + firstname: user.firstname, + lastname: user.lastname, + email: user.email, + role: user.role, + }) + ).toString("base64"); + + return res.redirect(`${frontendUrl}/auth/callback?token=${token}&user=${payload}`); + } catch (error) { + console.error("Erreur oauthCallback:", error); + return res.redirect( + `${frontendUrl}/auth/callback?error=${encodeURIComponent( + "Erreur serveur lors de la connexion externe." + )}` + ); + } +}; + const me = async (req, res) => { return res.json({ user: req.user, @@ -383,10 +466,13 @@ const deleteAccount = async (req, res) => { // 4. Supprimer les participations await tx.participation.deleteMany({ where: { userId } }); - // 5. Libérer les tickets utilisés par ce user + // 5. Détacher les tickets du compte sans les remettre en jeu : un code + // figure sur un ticket de caisse physique et n'est jouable qu'une + // fois. Le réinitialiser permettrait de rejouer indéfiniment le même + // code en supprimant puis recréant un compte. await tx.ticket.updateMany({ where: { userId }, - data: { userId: null, isUsed: false, usedAt: null }, + data: { userId: null }, }); // 6. Supprimer l'utilisateur @@ -415,6 +501,13 @@ const changePassword = async (req, res) => { const user = await prisma.user.findUnique({ where: { id: userId } }); + if (!user.password) { + return res.status(400).json({ + message: + "Ce compte utilise une connexion externe et n'a pas de mot de passe à modifier.", + }); + } + const isValid = await bcrypt.compare(currentPassword, user.password); if (!isValid) { return res.status(401).json({ message: "Mot de passe actuel incorrect." }); @@ -440,4 +533,5 @@ module.exports = { forgotPassword, changePassword, deleteAccount, + oauthCallback, }; \ No newline at end of file diff --git a/backend/src/controllers/employee.controller.js b/backend/src/controllers/employee.controller.js index 7dff989..33d8468 100644 --- a/backend/src/controllers/employee.controller.js +++ b/backend/src/controllers/employee.controller.js @@ -1,11 +1,11 @@ const prisma = require("../config/prisma"); -const { expireOverdueClaims } = require("../utils/gameWindow"); +const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow"); const getGainByTicketCode = async (req, res) => { try { const { code } = req.params; - await expireOverdueClaims(); + const gameWindow = await getGameWindow(); const ticket = await prisma.ticket.findUnique({ where: { code: code.toUpperCase() }, @@ -52,7 +52,7 @@ const getGainByTicketCode = async (req, res) => { }, claim: { id: ticket.participation.claim.id, - status: ticket.participation.claim.status, + status: effectiveClaimStatus(ticket.participation.claim.status, gameWindow), claimedAt: ticket.participation.claim.claimedAt, }, }); @@ -69,7 +69,7 @@ const deliverClaim = async (req, res) => { const { id } = req.params; const employeeId = req.user.id; - await expireOverdueClaims(); + const gameWindow = await getGameWindow(); const claim = await prisma.claim.findUnique({ where: { id: Number(id) }, @@ -87,7 +87,7 @@ const deliverClaim = async (req, res) => { }); } - if (claim.status === "CANCELLED") { + if (effectiveClaimStatus(claim.status, gameWindow) === "CANCELLED") { return res.status(400).json({ message: "Ce gain est expiré et ne peut plus être remis.", }); diff --git a/backend/src/controllers/game.controller.js b/backend/src/controllers/game.controller.js index 31f3345..9725af2 100644 --- a/backend/src/controllers/game.controller.js +++ b/backend/src/controllers/game.controller.js @@ -1,5 +1,13 @@ const prisma = require("../config/prisma"); -const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow"); +const transporter = require("../config/mailer"); +const { + getGameWindow, + effectiveClaimStatus, + participationClosedMessage, +} = require("../utils/gameWindow"); + +// Levée par la transaction quand le ticket a été consommé entre-temps. +class TicketDejaUtiliseError extends Error {} const participate = async (req, res) => { try { @@ -7,9 +15,13 @@ const participate = async (req, res) => { const userId = req.user.id; const gameWindow = await getGameWindow(); - if (!gameWindow.isOpen) { + + // Un code n'est jouable que pendant les 30 jours de jeu : ni avant + // l'ouverture, ni pendant la prolongation réservée aux retraits. + if (!gameWindow.isParticipationOpen) { return res.status(410).json({ - message: "Le jeu-concours est terminé. Vous ne pouvez plus tester de code ni réclamer de lot.", + message: participationClosedMessage(gameWindow), + phase: gameWindow.phase, }); } @@ -33,16 +45,24 @@ const participate = async (req, res) => { } const result = await prisma.$transaction(async (tx) => { - const updatedTicket = await tx.ticket.update({ - where: { id: ticket.id }, + // Le contrôle isUsed ci-dessus ne suffit pas : deux requêtes simultanées + // peuvent le franchir ensemble. On repasse donc par un update + // conditionnel, atomique, qui ne touche le ticket que s'il est encore + // libre — le perdant de la course obtient 0 ligne modifiée. + const { count } = await tx.ticket.updateMany({ + where: { id: ticket.id, isUsed: false }, data: { isUsed: true, usedAt: new Date(), userId, }, - include: { - lot: true, - }, + }); + + if (count === 0) throw new TicketDejaUtiliseError(); + + const updatedTicket = await tx.ticket.findUnique({ + where: { id: ticket.id }, + include: { lot: true }, }); const participation = await tx.participation.create({ @@ -67,8 +87,41 @@ const participate = async (req, res) => { }; }); + // La participation est déjà enregistrée et consultable dans « Mes gains » : + // un email qui ne part pas ne doit pas l'annuler ni consommer le ticket + // pour rien. On se contente de signaler l'échec. + let emailSent = true; + + try { + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: req.user.email, + subject: `Vous avez gagné : ${result.ticket.lot.name} !`, + html: ` +
+

Bravo ${req.user.firstname} !

+

Votre code ${result.ticket.code} est gagnant. Vous remportez :

+
+ ${result.ticket.lot.name} +
+ ${result.ticket.lot.description ? `

${result.ticket.lot.description}

` : ""} +

Présentez-vous en boutique avec ce code pour retirer votre lot${ + gameWindow.claimDeadline + ? `, avant le ${gameWindow.claimDeadline.toLocaleDateString("fr-FR")}` + : "" + }.

+

Retrouvez à tout moment vos gains dans votre espace « Mes gains ».

+
+ `, + }); + } catch (mailError) { + emailSent = false; + console.error("Erreur envoi email de gain:", mailError); + } + return res.status(201).json({ message: "Participation enregistrée avec succès.", + emailSent, gain: { name: result.ticket.lot.name, description: result.ticket.lot.description, @@ -80,6 +133,12 @@ const participate = async (req, res) => { }, }); } catch (error) { + if (error instanceof TicketDejaUtiliseError) { + return res.status(409).json({ + message: "Ce code a déjà été utilisé.", + }); + } + console.error("Erreur participation:", error); return res.status(500).json({ message: "Erreur serveur lors de la participation.", @@ -91,7 +150,7 @@ const myGains = async (req, res) => { try { const userId = req.user.id; - await expireOverdueClaims(); + const gameWindow = await getGameWindow(); const participations = await prisma.participation.findMany({ where: { userId }, @@ -112,7 +171,9 @@ const myGains = async (req, res) => { lot: participation.lot.name, description: participation.lot.description, value: participation.lot.value, - claimStatus: participation.claim?.status, + claimStatus: participation.claim + ? effectiveClaimStatus(participation.claim.status, gameWindow) + : undefined, wonAt: participation.createdAt, })), }); diff --git a/backend/src/middlewares/auth.middleware.js b/backend/src/middlewares/auth.middleware.js index e3d07b4..072501e 100644 --- a/backend/src/middlewares/auth.middleware.js +++ b/backend/src/middlewares/auth.middleware.js @@ -23,6 +23,8 @@ const authMiddleware = async (req, res, next) => { lastname: true, email: true, role: true, + provider: true, + password: true, }, }); @@ -32,7 +34,11 @@ const authMiddleware = async (req, res, next) => { }); } - req.user = user; + // req.user est renvoyé tel quel par /api/auth/me : on remplace le hash par + // un simple booléen, qui suffit au profil pour proposer « définir » ou + // « modifier » son mot de passe. + const { password, ...rest } = user; + req.user = { ...rest, hasPassword: Boolean(password) }; next(); } catch (error) { return res.status(401).json({ diff --git a/backend/src/routes/admin.routes.js b/backend/src/routes/admin.routes.js index 002d31d..acd0e54 100644 --- a/backend/src/routes/admin.routes.js +++ b/backend/src/routes/admin.routes.js @@ -6,12 +6,14 @@ const { createEmployee, drawWinner, getGrandPrizeWinner, + setGameSettings, + getGameSettings, } = require("../controllers/admin.controller"); const authMiddleware = require("../middlewares/auth.middleware"); const roleMiddleware = require("../middlewares/role.middleware"); const validate = require("../middlewares/validate.middleware"); -const { createEmployeeSchema } = require("../validations/admin.validation"); +const { createEmployeeSchema, gameSettingsSchema } = require("../validations/admin.validation"); const router = express.Router(); @@ -105,10 +107,48 @@ router.post("/draw", drawWinner); * - bearerAuth: [] * responses: * 200: - * description: Gagnant du gros lot - * 404: - * description: Aucun gagnant encore tiré + * description: Gagnant du gros lot, ou { winner - null } si aucun tirage n'a encore eu lieu */ router.get("/draw", getGrandPrizeWinner); +/** + * @swagger + * /api/admin/game-settings: + * get: + * summary: Fenêtre du jeu-concours (début, fin des participations, fin des retraits) + * tags: + * - Admin + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Configuration courante du jeu-concours + * put: + * summary: Lance le jeu-concours ou corrige sa date de début + * tags: + * - Admin + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - startDate + * properties: + * startDate: + * type: string + * format: date-time + * example: 2026-09-01T00:00:00.000Z + * responses: + * 200: + * description: Jeu-concours lancé ou mis à jour + * 400: + * description: Date de début invalide + */ +router.get("/game-settings", getGameSettings); +router.put("/game-settings", validate(gameSettingsSchema), setGameSettings); + module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/auth.routes.js b/backend/src/routes/auth.routes.js index 6053cc4..c697f00 100644 --- a/backend/src/routes/auth.routes.js +++ b/backend/src/routes/auth.routes.js @@ -1,8 +1,9 @@ const express = require("express"); -const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount } = require("../controllers/auth.controller"); +const { register, login, verifyEmail, verifyCode, forgotPassword, resetPassword, me, changePassword, deleteAccount, oauthCallback } = require("../controllers/auth.controller"); const authMiddleware = require("../middlewares/auth.middleware"); const validate = require("../middlewares/validate.middleware"); const { registerSchema, loginSchema } = require("../validations/auth.validation"); +const { passport, googleEnabled } = require("../config/passport"); const router = express.Router(); @@ -161,4 +162,55 @@ router.post("/verify-code", verifyCode); router.patch("/change-password", authMiddleware, changePassword); router.delete("/account", authMiddleware, deleteAccount); +/** + * @swagger + * /api/auth/google: + * get: + * summary: Démarre la connexion via Google (redirection OAuth 2.0) + * tags: + * - Auth + * responses: + * 302: + * description: Redirection vers l'écran de consentement Google. + * 503: + * description: Connexion Google non configurée sur ce serveur. + */ + +/** + * @swagger + * /api/auth/google/callback: + * get: + * summary: Retour de Google, émet le JWT et redirige vers le front + * tags: + * - Auth + * responses: + * 302: + * description: Redirection vers FRONTEND_URL/auth/callback avec le token. + */ +if (googleEnabled) { + router.get( + "/google", + passport.authenticate("google", { scope: ["profile", "email"], session: false }) + ); + + router.get( + "/google/callback", + passport.authenticate("google", { + session: false, + failureRedirect: `${process.env.FRONTEND_URL || "http://localhost:5173"}/auth/callback?error=${encodeURIComponent( + "Connexion Google refusée." + )}`, + }), + oauthCallback + ); +} else { + // Sans identifiants configurés, on répond explicitement plutôt que de + // laisser passport planter au démarrage. + const notConfigured = (req, res) => + res.status(503).json({ message: "La connexion Google n'est pas configurée sur ce serveur." }); + + router.get("/google", notConfigured); + router.get("/google/callback", notConfigured); +} + module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/game.routes.js b/backend/src/routes/game.routes.js index 26a5b03..69c4eaa 100644 --- a/backend/src/routes/game.routes.js +++ b/backend/src/routes/game.routes.js @@ -1,6 +1,7 @@ const express = require("express"); const { participate, myGains } = require("../controllers/game.controller"); const authMiddleware = require("../middlewares/auth.middleware"); +const roleMiddleware = require("../middlewares/role.middleware"); const validate = require("../middlewares/validate.middleware"); const { participateSchema } = require("../validations/game.validation"); @@ -20,7 +21,9 @@ const router = express.Router(); * description: Participation enregistrée */ -router.post("/participate", authMiddleware, validate(participateSchema), participate); +// Le jeu est reserve aux clients : un ADMIN ou un EMPLOYEE qui joue +// consommerait des tickets et fausserait les statistiques. +router.post("/participate", authMiddleware, roleMiddleware("CLIENT"), validate(participateSchema), participate); /** * @swagger @@ -36,6 +39,6 @@ router.post("/participate", authMiddleware, validate(participateSchema), partici * description: Liste des gains */ -router.get("/my-gains", authMiddleware, myGains); +router.get("/my-gains", authMiddleware, roleMiddleware("CLIENT"), myGains); module.exports = router; \ No newline at end of file diff --git a/backend/src/utils/gameWindow.js b/backend/src/utils/gameWindow.js index eaa34f1..65c19c1 100644 --- a/backend/src/utils/gameWindow.js +++ b/backend/src/utils/gameWindow.js @@ -1,23 +1,41 @@ const prisma = require("../config/prisma"); +// Le concours traverse cinq états, et non deux : confondre « pas encore +// ouvert » avec « terminé » produit des messages faux côté joueur et laisse +// passer le tirage au sort avant le début du jeu. +const PHASES = { + NOT_CONFIGURED: "NOT_CONFIGURED", + SCHEDULED: "SCHEDULED", + OPEN: "OPEN", + CLAIM_ONLY: "CLAIM_ONLY", + CLOSED: "CLOSED", +}; + const GAME_DURATION_DAYS = 30; const CLAIM_EXTENSION_DAYS = 30; const DAY_MS = 24 * 60 * 60 * 1000; -// Fenêtre pendant laquelle un joueur peut à la fois tester son code et -// réclamer son lot : 30 jours de jeu + 30 jours supplémentaires à compter -// de startDate. Tant qu'aucune GameSettings n'existe, le jeu est considéré -// comme non démarré et donc ouvert (comportement de dev par défaut). -async function getGameWindow() { +// Deux échéances distinctes à partir de startDate : +// - participationDeadline (30 j) : dernier moment pour tester un code ; +// - claimDeadline (60 j) : dernier moment pour retirer un lot déjà gagné. +// Les 30 jours supplémentaires servent uniquement au retrait, pas au jeu. +// Tant qu'aucune GameSettings n'existe, le jeu est considéré comme non +// configuré et donc ouvert (comportement de dev par défaut). +async function getGameWindow(now = new Date()) { const settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + // Tant que l'administrateur n'a pas fixé de date, le concours n'existe pas : + // rien n'est jouable et aucun tirage n'est possible. Ouvrir par défaut + // reviendrait à faire gagner des lots dans un jeu qui n'a jamais été lancé. if (!settings) { return { configured: false, + phase: PHASES.NOT_CONFIGURED, startDate: null, participationDeadline: null, claimDeadline: null, - isOpen: true, + isParticipationOpen: false, + isOpen: false, }; } @@ -26,26 +44,57 @@ async function getGameWindow() { settings.startDate.getTime() + (GAME_DURATION_DAYS + CLAIM_EXTENSION_DAYS) * DAY_MS ); + const hasStarted = now >= settings.startDate; + const isParticipationOpen = hasStarted && now <= participationDeadline; + const isOpen = hasStarted && now <= claimDeadline; + + let phase; + if (!hasStarted) phase = PHASES.SCHEDULED; + else if (isParticipationOpen) phase = PHASES.OPEN; + else if (isOpen) phase = PHASES.CLAIM_ONLY; + else phase = PHASES.CLOSED; + return { configured: true, + phase, startDate: settings.startDate, participationDeadline, claimDeadline, - isOpen: new Date() <= claimDeadline, + hasStarted, + isParticipationOpen, + isOpen, }; } -// Bascule en CANCELLED les gains PENDING non réclamés une fois la fenêtre -// fermée. Appelé au fil de l'eau (pas de cron) avant toute lecture/écriture -// de claims, ce qui suffit pour ce projet. -async function expireOverdueClaims() { - const window = await getGameWindow(); - if (window.isOpen) return; - - await prisma.claim.updateMany({ - where: { status: "PENDING" }, - data: { status: "CANCELLED" }, - }); +// L'expiration d'un gain est *dérivée* de la fenêtre de jeu, jamais écrite en +// base : un PENDING dont le délai de retrait est passé est présenté comme +// CANCELLED, mais la ligne reste intacte. Corriger une startDate erronée +// suffit donc à rétablir les gains, et aucune lecture ne modifie la base. +function effectiveClaimStatus(status, gameWindow) { + if (status === "PENDING" && !gameWindow.isOpen) return "CANCELLED"; + return status; } -module.exports = { getGameWindow, expireOverdueClaims }; +// Message destiné au joueur qui tente de jouer hors de la fenêtre : chaque +// phase a sa propre explication, avec la date utile quand elle existe. +function participationClosedMessage(gameWindow) { + switch (gameWindow.phase) { + case PHASES.NOT_CONFIGURED: + return "Le jeu-concours n'est pas encore ouvert. Revenez prochainement."; + case PHASES.SCHEDULED: + return `Le jeu-concours ouvrira le ${gameWindow.startDate.toLocaleDateString("fr-FR")}.`; + case PHASES.CLAIM_ONLY: + return "La période de participation est terminée. Vous pouvez encore retirer les lots déjà gagnés."; + default: + return "Le jeu-concours est terminé. Vous ne pouvez plus tester de code ni réclamer de lot."; + } +} + +module.exports = { + getGameWindow, + effectiveClaimStatus, + participationClosedMessage, + PHASES, + GAME_DURATION_DAYS, + CLAIM_EXTENSION_DAYS, +}; diff --git a/backend/src/validations/admin.validation.js b/backend/src/validations/admin.validation.js index 6ea9852..c6771bf 100644 --- a/backend/src/validations/admin.validation.js +++ b/backend/src/validations/admin.validation.js @@ -9,6 +9,13 @@ const createEmployeeSchema = z.object({ gender: z.string().optional(), }); +const gameSettingsSchema = z.object({ + startDate: z + .string() + .refine((value) => !Number.isNaN(new Date(value).getTime()), "Date de début invalide"), +}); + module.exports = { createEmployeeSchema, + gameSettingsSchema, }; \ No newline at end of file diff --git a/backend/tests/draw.test.js b/backend/tests/draw.test.js new file mode 100644 index 0000000..763e776 --- /dev/null +++ b/backend/tests/draw.test.js @@ -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() + ); +}); diff --git a/backend/tests/drawNotification.test.js b/backend/tests/drawNotification.test.js new file mode 100644 index 0000000..80c26f9 --- /dev/null +++ b/backend/tests/drawNotification.test.js @@ -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); + }); +}); diff --git a/backend/tests/e2e.flow.test.js b/backend/tests/e2e.flow.test.js index fd0b741..ddb2ae2 100644 --- a/backend/tests/e2e.flow.test.js +++ b/backend/tests/e2e.flow.test.js @@ -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 () => { - await prisma.claim.deleteMany(); - await prisma.participation.deleteMany(); - await prisma.ticket.deleteMany(); - await prisma.user.deleteMany(); + await prisma.user.deleteMany({ where: { email: testEmail } }); + await prisma.ticket.deleteMany({ where: { code: ticketCode } }); }); afterAll(async () => { + const user = await prisma.user.findUnique({ where: { email: testEmail } }); + if (user) { + await prisma.claim.deleteMany({ where: { participation: { userId: user.id } } }); + await prisma.participation.deleteMany({ where: { userId: user.id } }); + } + if (createdTicketId) { + await prisma.ticket.delete({ where: { id: createdTicketId } }).catch(() => {}); + } + if (user) { + await prisma.user.delete({ where: { id: user.id } }); + } await prisma.$disconnect(); }); describe("THE TIP TOP - FULL E2E FLOW", () => { - let token; - let ticketCode = "ABC123"; - - // ------------------------ - // 1. REGISTER - // ------------------------ test("Register user", async () => { - const res = await request(app) - .post("/api/auth/register") - .send({ - firstname: "Test", - lastname: "User", - email: "test@test.com", - password: "Password123", - }); + const res = await request(app).post("/api/auth/register").send({ + firstname: "E2E", + lastname: "Test", + email: testEmail, + password: "Password123", + }); - expect([201, 409]).toContain(res.statusCode); + expect(res.statusCode).toBe(201); }); - // ------------------------ - // 2. LOGIN - // ------------------------ test("Login user", async () => { - // Validation du user pour bypasser vérification email await prisma.user.update({ - where: { email: "test@test.com" }, + where: { email: testEmail }, data: { isEmailVerified: true }, }); - const res = await request(app) - .post("/api/auth/login") - .send({ - email: "test@test.com", - password: "Password123", - }); + const res = await request(app).post("/api/auth/login").send({ + email: testEmail, + password: "Password123", + }); expect(res.statusCode).toBe(200); expect(res.body.token).toBeDefined(); token = res.body.token; }); - // ------------------------ - // 3. PARTICIPATE GAME - // ------------------------ - test("Participate to game", async () => { - const res = await request(app) - .post("/api/game/participate") - .set("Authorization", `Bearer ${token}`) - .send({ code: ticketCode }); - - expect([200, 201, 404, 409, 400]).toContain(res.statusCode); - console.log("Ticket in DB before participate:", ticketCode); - }); - - // ------------------------ - // 4. MY GAINS - // ------------------------ - test("Get my gains", async () => { - const res = await request(app) - .get("/api/game/my-gains") - .set("Authorization", `Bearer ${token}`); - - expect(res.statusCode).toBe(200); - expect(res.body.gains).toBeDefined(); - }); - - // ------------------------ - // 3. GENERER UN TICKET - // ------------------------ - test("Create a ticket for testing", async () => { - const lot = await prisma.lot.create({ - data: { - name: "Lot Test", - description: "Lot pour test E2E", - value: 10, - percentage: 100 - }, - }); + test("Create a dedicated ticket for this test", async () => { + const lot = await prisma.lot.findFirst(); + expect(lot).not.toBeNull(); const ticket = await prisma.ticket.create({ - data: { - code: ticketCode, - lotId: lot.id - } + data: { code: ticketCode, lotId: lot.id }, }); + createdTicketId = ticket.id; - expect(ticket).toBeDefined(); expect(ticket.code).toBe(ticketCode); }); - // ------------------------ - // 4. PARTICIPATE GAME - // ------------------------ test("Participate to game", async () => { const res = await request(app) .post("/api/game/participate") .set("Authorization", `Bearer ${token}`) .send({ code: ticketCode }); - expect([200, 201, 404, 409, 400]).toContain(res.statusCode); + expect(res.statusCode).toBe(201); + expect(res.body.gain).toBeDefined(); }); - // ------------------------ - // 5. MY GAINS - // ------------------------ test("Get my gains", async () => { const res = await request(app) .get("/api/game/my-gains") @@ -136,11 +99,9 @@ describe("THE TIP TOP - FULL E2E FLOW", () => { expect(res.statusCode).toBe(200); expect(res.body.gains).toBeDefined(); + expect(res.body.gains.some((g) => g.ticketCode === ticketCode)).toBe(true); }); - // ------------------------ - // 6. ADMIN STATS (CLIENT access denied) - // ------------------------ test("Admin stats - CLIENT access denied", async () => { const res = await request(app) .get("/api/admin/stats") @@ -149,4 +110,4 @@ describe("THE TIP TOP - FULL E2E FLOW", () => { expect(res.statusCode).toBe(403); expect(res.body.message || res.body).toBeDefined(); }); -}); \ No newline at end of file +}); diff --git a/backend/tests/gainNotification.test.js b/backend/tests/gainNotification.test.js new file mode 100644 index 0000000..5e3bf99 --- /dev/null +++ b/backend/tests/gainNotification.test.js @@ -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(); + }); +}); diff --git a/backend/tests/gameFlow.test.js b/backend/tests/gameFlow.test.js new file mode 100644 index 0000000..040b603 --- /dev/null +++ b/backend/tests/gameFlow.test.js @@ -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); + }); +}); diff --git a/backend/tests/gamePhases.test.js b/backend/tests/gamePhases.test.js new file mode 100644 index 0000000..8f87ea0 --- /dev/null +++ b/backend/tests/gamePhases.test.js @@ -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"); + }); +}); diff --git a/backend/tests/googlePassword.test.js b/backend/tests/googlePassword.test.js new file mode 100644 index 0000000..b0ceb71 --- /dev/null +++ b/backend/tests/googlePassword.test.js @@ -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; diff --git a/backend/tests/register.mail.test.js b/backend/tests/register.mail.test.js new file mode 100644 index 0000000..463821c --- /dev/null +++ b/backend/tests/register.mail.test.js @@ -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"); + }); +}); diff --git a/backend/tests/setup.js b/backend/tests/setup.js deleted file mode 100644 index 0f5b280..0000000 --- a/backend/tests/setup.js +++ /dev/null @@ -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(); -}); diff --git a/deploy/dev/docker-compose.yml b/deploy/dev/docker-compose.yml index c1bafc4..87014ba 100644 --- a/deploy/dev/docker-compose.yml +++ b/deploy/dev/docker-compose.yml @@ -38,6 +38,7 @@ services: JWT_SECRET: dev_secret_change_me APP_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr API_PUBLIC_URL: https://api-dev.dsp5-archi-o24a-4-5-g4.fr + FRONTEND_URL: https://dev.dsp5-archi-o24a-4-5-g4.fr command: > sh -c " diff --git a/deploy/preprod/docker-compose.yml b/deploy/preprod/docker-compose.yml index 5296846..9f7c7d2 100644 --- a/deploy/preprod/docker-compose.yml +++ b/deploy/preprod/docker-compose.yml @@ -38,6 +38,7 @@ services: JWT_SECRET: preprod_secret_change_me APP_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr API_PUBLIC_URL: https://api-preprod.dsp5-archi-o24a-4-5-g4.fr + FRONTEND_URL: https://preprod.dsp5-archi-o24a-4-5-g4.fr command: > sh -c " diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml index cae0a67..2f66ca7 100644 --- a/deploy/prod/docker-compose.yml +++ b/deploy/prod/docker-compose.yml @@ -38,6 +38,7 @@ services: JWT_SECRET: prod_secret_change_me APP_URL: https://api.dsp5-archi-o24a-4-5-g4.fr API_PUBLIC_URL: https://api.dsp5-archi-o24a-4-5-g4.fr + FRONTEND_URL: https://dsp5-archi-o24a-4-5-g4.fr command: > sh -c " diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ccc4043..ca02521 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -146,14 +146,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -266,13 +266,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", - "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.8" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -307,18 +307,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", - "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", + "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", + "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -326,9 +326,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "dev": true, "license": "MIT", "dependencies": { @@ -492,10 +492,47 @@ "node": ">=20.19.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", + "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.3", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", + "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.10.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", - "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", "dev": true, "license": "MIT", "dependencies": { @@ -550,9 +587,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", - "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -754,10 +791,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@oxc-project/types": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.144.0.tgz", - "integrity": "sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -765,9 +821,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.4.tgz", - "integrity": "sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -782,9 +838,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -799,9 +855,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.4.tgz", - "integrity": "sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -816,9 +872,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.4.tgz", - "integrity": "sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -833,9 +889,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.4.tgz", - "integrity": "sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -850,9 +906,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.4.tgz", - "integrity": "sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -867,9 +923,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.4.tgz", - "integrity": "sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -884,9 +940,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.4.tgz", - "integrity": "sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -901,9 +957,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.4.tgz", - "integrity": "sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -918,9 +974,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.4.tgz", - "integrity": "sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -935,9 +991,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.4.tgz", - "integrity": "sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -952,9 +1008,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.4.tgz", - "integrity": "sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -968,10 +1024,63 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.4.tgz", - "integrity": "sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -986,9 +1095,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.4.tgz", - "integrity": "sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -1017,49 +1126,49 @@ "license": "MIT" }, "node_modules/@tailwindcss/node": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", - "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.24.1", - "jiti": "^2.7.0", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.3" + "tailwindcss": "4.3.0" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", - "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-arm64": "4.3.3", - "@tailwindcss/oxide-darwin-x64": "4.3.3", - "@tailwindcss/oxide-freebsd-x64": "4.3.3", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", - "@tailwindcss/oxide-linux-x64-musl": "4.3.3", - "@tailwindcss/oxide-wasm32-wasi": "4.3.3", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", - "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", "cpu": [ "arm64" ], @@ -1074,9 +1183,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", - "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", "cpu": [ "arm64" ], @@ -1091,9 +1200,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", - "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", "cpu": [ "x64" ], @@ -1108,9 +1217,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", - "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", "cpu": [ "x64" ], @@ -1125,9 +1234,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", - "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", "cpu": [ "arm" ], @@ -1142,9 +1251,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", - "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", "cpu": [ "arm64" ], @@ -1159,9 +1268,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", - "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", "cpu": [ "arm64" ], @@ -1176,9 +1285,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", - "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", "cpu": [ "x64" ], @@ -1193,9 +1302,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", - "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", "cpu": [ "x64" ], @@ -1210,9 +1319,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", - "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -1228,11 +1337,11 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.11.1", - "@emnapi/runtime": "^1.11.1", - "@emnapi/wasi-threads": "^1.2.2", + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", "@napi-rs/wasm-runtime": "^1.1.4", - "@tybys/wasm-util": "^0.10.2", + "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "engines": { @@ -1240,18 +1349,18 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", + "version": "1.10.0", "dev": true, "inBundle": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.2", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", + "version": "1.10.0", "dev": true, "inBundle": true, "license": "MIT", @@ -1261,7 +1370,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", + "version": "1.2.1", "dev": true, "inBundle": true, "license": "MIT", @@ -1289,7 +1398,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.2", + "version": "0.10.1", "dev": true, "inBundle": true, "license": "MIT", @@ -1306,9 +1415,9 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", - "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", "cpu": [ "arm64" ], @@ -1323,9 +1432,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", - "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", "cpu": [ "x64" ], @@ -1340,15 +1449,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", - "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.3", - "@tailwindcss/oxide": "4.3.3", - "tailwindcss": "4.3.3" + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -1444,6 +1553,17 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -1492,9 +1612,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.18", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", - "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", "dependencies": { @@ -1502,9 +1622,9 @@ } }, "node_modules/@types/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", "peerDependencies": { @@ -1512,13 +1632,13 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", - "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", "dev": true, "license": "MIT", "dependencies": { - "@rolldown/pluginutils": "^1.0.1" + "@rolldown/pluginutils": "^1.0.0" }, "engines": { "node": "^20.19.0 || >=22.12.0" @@ -1651,9 +1771,9 @@ } }, "node_modules/acorn": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", - "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", "bin": { @@ -1746,9 +1866,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.11.14", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.14.tgz", - "integrity": "sha512-JyJ954WzuIR8/FFzX0o5krdSTrBAkcCSRfWSleRsIHSWV+cZe2FI1PKggVkFke1hBldRs+LRxUczzE9iPmgZww==", + "version": "2.10.35", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", + "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -1769,22 +1889,22 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" } }, "node_modules/browserslist": { - "version": "4.28.8", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", - "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -1802,11 +1922,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.11.12", - "caniuse-lite": "^1.0.30001809", - "electron-to-chromium": "^1.5.402", - "node-releases": "^2.0.53", - "update-browserslist-db": "^1.3.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -1816,9 +1936,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001809", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", - "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", + "version": "1.0.30001797", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", + "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", "dev": true, "funding": [ { @@ -1984,16 +2104,16 @@ "peer": true }, "node_modules/electron-to-chromium": { - "version": "1.5.408", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.408.tgz", - "integrity": "sha512-SLoprcYpJ/OH2v2ps0+N5biv9H4/KBT3+YmmDew64TwK5y9j2wv7pMOFY7IorVkyMtEyLSCRlXKLsNlakeAlPw==", + "version": "1.5.371", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", + "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.24.5", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", - "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", + "integrity": "sha512-yJN/BOOLxcOW2aQgeif9mSnaUB8KtvmMMp56oA1kx1CRfBKbhZm2pJ+NBY+3eOboHxix8lfjWpHE0Ei5U8RbSA==", "dev": true, "license": "MIT", "dependencies": { @@ -2018,9 +2138,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", - "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", "dev": true, "license": "MIT" }, @@ -2048,19 +2168,16 @@ } }, "node_modules/eslint": { - "version": "10.8.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", - "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.1.tgz", + "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", "dev": true, "license": "MIT", - "workspaces": [ - "packages/*" - ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", - "@eslint/config-helpers": "^0.7.0", + "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", @@ -2084,7 +2201,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "minimatch": "^10.2.5", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -2127,9 +2244,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.4.tgz", - "integrity": "sha512-7bqTKz7T0r+HKWFarNXByDE9/5+73wI2ru+M3zuqGbR7s/b/5/pQJXZoufWlrngqGqoZto73ZkGumCdLxk+4rw==", + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2336,9 +2453,9 @@ } }, "node_modules/flatted": { - "version": "3.4.4", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", - "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -2381,9 +2498,9 @@ } }, "node_modules/globals": { - "version": "17.11.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.11.0.tgz", - "integrity": "sha512-Z2I8hM+PbJDXQDq3Icgpzv+mPdwr68iZUU9d5WW4FuXfDUQfkZaZuvjMv42/5crNyw154+9+VWXbYrUgDXbxNw==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -2962,13 +3079,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.8" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -2985,9 +3102,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -3011,9 +3128,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.53", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", - "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", + "version": "2.0.47", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", + "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", "dev": true, "license": "MIT", "engines": { @@ -3132,9 +3249,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -3145,9 +3262,9 @@ } }, "node_modules/postcss": { - "version": "8.5.26", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", - "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -3165,7 +3282,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.17", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3210,24 +3327,24 @@ } }, "node_modules/react": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", - "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.8", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", - "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.8" + "react": "^19.2.7" } }, "node_modules/react-is": { @@ -3239,9 +3356,9 @@ "peer": true }, "node_modules/react-router": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", - "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", + "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -3261,12 +3378,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", - "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "version": "7.17.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", + "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", "license": "MIT", "dependencies": { - "react-router": "7.18.2" + "react-router": "7.17.0" }, "engines": { "node": ">=20.0.0" @@ -3301,13 +3418,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.4.tgz", - "integrity": "sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.144.0", + "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -3317,20 +3434,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.4", - "@rolldown/binding-darwin-arm64": "1.2.4", - "@rolldown/binding-darwin-x64": "1.2.4", - "@rolldown/binding-freebsd-x64": "1.2.4", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.4", - "@rolldown/binding-linux-arm64-gnu": "1.2.4", - "@rolldown/binding-linux-arm64-musl": "1.2.4", - "@rolldown/binding-linux-ppc64-gnu": "1.2.4", - "@rolldown/binding-linux-s390x-gnu": "1.2.4", - "@rolldown/binding-linux-x64-gnu": "1.2.4", - "@rolldown/binding-linux-x64-musl": "1.2.4", - "@rolldown/binding-openharmony-arm64": "1.2.4", - "@rolldown/binding-win32-arm64-msvc": "1.2.4", - "@rolldown/binding-win32-x64-msvc": "1.2.4" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/saxes": { @@ -3443,9 +3561,9 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", - "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", "dev": true, "license": "MIT" }, @@ -3553,6 +3671,14 @@ "node": ">=20" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3577,9 +3703,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", - "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "dev": true, "funding": [ { @@ -3618,16 +3744,16 @@ } }, "node_modules/vite": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", - "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { - "lightningcss": "^1.33.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.25", - "rolldown": "~1.2.1", + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", "tinyglobby": "^0.2.17" }, "bin": { @@ -3644,7 +3770,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0", + "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -3695,267 +3821,6 @@ } } }, - "node_modules/vite/node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/vite/node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, "node_modules/vitest": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index afcc658..ff45923 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -5,6 +5,8 @@ import ProtectedRoute from './components/ProtectedRoute.jsx' import CookieBanner from './components/CookieBanner.jsx' import Connexion from './pages/Connexion' import Inscription from './pages/Inscription' +import AuthCallback from './pages/AuthCallback' +import DefinirMotDePasse from './pages/DefinirMotDePasse' import Accueil from './pages/Accueil' import ReglesJeu from './pages/ReglesJeu' import MesGains from './pages/MesGains' @@ -29,6 +31,8 @@ export default function App() { } /> } /> } /> + } /> + } /> {/* Pages publiques */} } /> diff --git a/frontend/src/components/Header.jsx b/frontend/src/components/Header.jsx index 0862d4f..ae0c831 100644 --- a/frontend/src/components/Header.jsx +++ b/frontend/src/components/Header.jsx @@ -9,7 +9,7 @@ export default function Header() { const navigate = useNavigate() const { user, logout } = useAuth() const isLoggedIn = !!user - const logoContent = Logo de Thé Tip Top + const logoContent = Logo de Thé Tip Top const handleLogout = () => { logout() @@ -40,12 +40,12 @@ export default function Header() { )} {/* Nav desktop */} -